@@ -5,11 +5,17 @@ import (
55 "encoding/json"
66 "fmt"
77 "net/http"
8+ "regexp"
89 "strings"
910 "sync"
1011 "time"
12+
13+ log "github.com/sirupsen/logrus"
1114)
1215
16+ // validLocalPartRegex validates that the local part only contains allowed characters: a-z, 0-9, -, _, .
17+ var validLocalPartRegex = regexp .MustCompile (`^[a-z0-9\-_.]*$` )
18+
1319type UserliService interface {
1420 GetAliases (ctx context.Context , email string ) ([]string , error )
1521 GetDomain (ctx context.Context , domain string ) (bool , error )
@@ -18,8 +24,9 @@ type UserliService interface {
1824}
1925
2026type Userli struct {
21- token string
22- baseURL string
27+ token string
28+ baseURL string
29+ delimiter string
2330
2431 mu sync.RWMutex // Protects Client field
2532 Client * http.Client
@@ -49,6 +56,14 @@ func WithTransport(transport *http.Transport) Option {
4956 }
5057}
5158
59+ func WithDelimiter (delimiter string ) Option {
60+ return func (u * Userli ) {
61+ u .mu .Lock ()
62+ defer u .mu .Unlock ()
63+ u .delimiter = delimiter
64+ }
65+ }
66+
5267// WithTimeout sets a custom timeout (creates a new client with the specified timeout, thread-safe)
5368func WithTimeout (timeout time.Duration ) Option {
5469 return func (u * Userli ) {
@@ -113,11 +128,13 @@ func NewUserli(token, baseURL string, opts ...Option) *Userli {
113128}
114129
115130func (u * Userli ) GetAliases (ctx context.Context , email string ) ([]string , error ) {
116- if ! strings .Contains (email , "@" ) {
117- return []string {}, nil
131+ sanitizedEmail , err := u .sanitizeEmail (email )
132+ if err != nil {
133+ log .WithError (err ).WithField ("email" , email ).Info ("unable to process the alias" )
134+ return []string {}, err
118135 }
119136
120- resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/alias/%s" , u .baseURL , email ))
137+ resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/alias/%s" , u .baseURL , sanitizedEmail ))
121138 if err != nil {
122139 return []string {}, err
123140 }
@@ -135,6 +152,7 @@ func (u *Userli) GetAliases(ctx context.Context, email string) ([]string, error)
135152func (u * Userli ) GetDomain (ctx context.Context , domain string ) (bool , error ) {
136153 resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/domain/%s" , u .baseURL , domain ))
137154 if err != nil {
155+ log .WithError (err ).WithField ("domain" , domain ).Info ("unable to process the domain" )
138156 return false , err
139157 }
140158 defer resp .Body .Close ()
@@ -149,11 +167,13 @@ func (u *Userli) GetDomain(ctx context.Context, domain string) (bool, error) {
149167}
150168
151169func (u * Userli ) GetMailbox (ctx context.Context , email string ) (bool , error ) {
152- if ! strings .Contains (email , "@" ) {
153- return false , nil
170+ sanitizedEmail , err := u .sanitizeEmail (email )
171+ if err != nil {
172+ log .WithError (err ).WithField ("email" , email ).Info ("unable to process the mailbox" )
173+ return false , err
154174 }
155175
156- resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/mailbox/%s" , u .baseURL , email ))
176+ resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/mailbox/%s" , u .baseURL , sanitizedEmail ))
157177 if err != nil {
158178 return false , err
159179 }
@@ -169,11 +189,13 @@ func (u *Userli) GetMailbox(ctx context.Context, email string) (bool, error) {
169189}
170190
171191func (u * Userli ) GetSenders (ctx context.Context , email string ) ([]string , error ) {
172- if ! strings .Contains (email , "@" ) {
173- return []string {}, nil
192+ sanitizedEmail , err := u .sanitizeEmail (email )
193+ if err != nil {
194+ log .WithError (err ).WithField ("email" , email ).Info ("unable to process the senders" )
195+ return []string {}, err
174196 }
175197
176- resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/senders/%s" , u .baseURL , email ))
198+ resp , err := u .call (ctx , fmt .Sprintf ("%s/api/postfix/senders/%s" , u .baseURL , sanitizedEmail ))
177199 if err != nil {
178200 return []string {}, err
179201 }
@@ -244,3 +266,46 @@ func (u *Userli) call(ctx context.Context, url string) (*http.Response, error) {
244266
245267 return resp , nil
246268}
269+
270+ func (u * Userli ) sanitizeEmail (email string ) (string , error ) {
271+ // Normalize email: lowercase and remove whitespace
272+ email = strings .ToLower (email )
273+ email = strings .TrimSpace (email )
274+
275+ // Remove all non-visible characters (control characters, zero-width spaces, etc.)
276+ email = strings .TrimFunc (email , func (r rune ) bool {
277+ return r < 33 || r == 127 || // ASCII control characters
278+ r == 0x200B || // Zero-width space
279+ r == 0x200C || // Zero-width non-joiner
280+ r == 0x200D || // Zero-width joiner
281+ r == 0xFEFF // Zero-width no-break space (BOM)
282+ })
283+
284+ // Split email by @
285+ parts := strings .Split (email , "@" )
286+ if len (parts ) != 2 {
287+ return "" , fmt .Errorf ("invalid email format: %s" , email )
288+ }
289+
290+ localPart := parts [0 ]
291+ domain := parts [1 ]
292+
293+ // Remove recipient delimiter from local part if configured
294+ if u .delimiter != "" {
295+ if idx := strings .Index (localPart , u .delimiter ); idx != - 1 {
296+ localPart = localPart [:idx ]
297+ }
298+ }
299+
300+ // Validate local part matches allowed pattern
301+ if ! validLocalPartRegex .MatchString (localPart ) {
302+ return "" , fmt .Errorf ("invalid local part: %s" , localPart )
303+ }
304+
305+ // Validate that local part is not empty
306+ if localPart == "" {
307+ return "" , fmt .Errorf ("invalid email format: empty local part after sanitization" )
308+ }
309+
310+ return fmt .Sprintf ("%s@%s" , localPart , domain ), nil
311+ }
0 commit comments