Skip to content

Commit 2dc6ae1

Browse files
authored
Merge pull request #58 from systemli/Support-Postfix-Recipent-Delimiter
✨ Support Postfix Recipent Delimiter
2 parents e4bfa76 + 0008cf5 commit 2dc6ae1

File tree

6 files changed

+352
-19
lines changed

6 files changed

+352
-19
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The adapter is configured via environment variables:
1111

1212
- `USERLI_TOKEN`: The token to authenticate against the userli API.
1313
- `USERLI_BASE_URL`: The base URL of the userli API.
14+
- `POSTFIX_RECIPIENT_DELIMITER`: The recipient delimiter used in Postfix (e.g., `+`). Default: empty.
1415
- `SOCKETMAP_LISTEN_ADDR`: The address to listen on for socketmap requests. Default: `:10001`.
1516
- `METRICS_LISTEN_ADDR`: The address to listen on for metrics. Default: `:10002`.
1617

config.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ type Config struct {
1414
// UserliBaseURL is the base URL for the userli service.
1515
UserliBaseURL string
1616

17+
// PostfixRecipientDelimiter is the recipient delimiter used by Postfix.
18+
PostfixRecipientDelimiter string
19+
1720
// SocketmapListenAddr is the address to listen for socketmap requests.
1821
SocketmapListenAddr string
1922

@@ -55,6 +58,8 @@ func NewConfig() *Config {
5558
log.Fatal("USERLI_TOKEN is required")
5659
}
5760

61+
postfixRecipientDelimiter := os.Getenv("POSTFIX_RECIPIENT_DELIMITER")
62+
5863
socketmapListenAddr := os.Getenv("SOCKETMAP_LISTEN_ADDR")
5964
if socketmapListenAddr == "" {
6065
socketmapListenAddr = ":10001"
@@ -66,9 +71,10 @@ func NewConfig() *Config {
6671
}
6772

6873
return &Config{
69-
UserliBaseURL: userliBaseURL,
70-
UserliToken: userliToken,
71-
SocketmapListenAddr: socketmapListenAddr,
72-
MetricsListenAddr: metricsListenAddr,
74+
UserliBaseURL: userliBaseURL,
75+
UserliToken: userliToken,
76+
PostfixRecipientDelimiter: postfixRecipientDelimiter,
77+
SocketmapListenAddr: socketmapListenAddr,
78+
MetricsListenAddr: metricsListenAddr,
7379
}
7480
}

config_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,23 @@ func (s *ConfigTestSuite) TestNewConfig() {
3636

3737
s.Equal("token", config.UserliToken)
3838
s.Equal("http://localhost:8000", config.UserliBaseURL)
39+
s.Equal("", config.PostfixRecipientDelimiter)
3940
s.Equal(":10001", config.SocketmapListenAddr)
4041
s.Equal(":10002", config.MetricsListenAddr)
4142
})
4243

4344
s.Run("custom config", func() {
4445
os.Setenv("USERLI_TOKEN", "token")
4546
os.Setenv("USERLI_BASE_URL", "http://example.com")
47+
os.Setenv("POSTFIX_RECIPIENT_DELIMITER", "+")
4648
os.Setenv("SOCKETMAP_LISTEN_ADDR", ":20001")
4749
os.Setenv("METRICS_LISTEN_ADDR", ":20002")
4850

4951
config := NewConfig()
5052

5153
s.Equal("token", config.UserliToken)
5254
s.Equal("http://example.com", config.UserliBaseURL)
55+
s.Equal("+", config.PostfixRecipientDelimiter)
5356
s.Equal(":20001", config.SocketmapListenAddr)
5457
s.Equal(":20002", config.MetricsListenAddr)
5558
})

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
func main() {
1313
config := NewConfig()
14-
userli := NewUserli(config.UserliToken, config.UserliBaseURL)
14+
userli := NewUserli(config.UserliToken, config.UserliBaseURL, WithDelimiter(config.PostfixRecipientDelimiter))
1515
socketmapAdapter := NewSocketmapAdapter(userli)
1616

1717
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

userli.go

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1319
type 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

2026
type 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)
5368
func WithTimeout(timeout time.Duration) Option {
5469
return func(u *Userli) {
@@ -113,11 +128,13 @@ func NewUserli(token, baseURL string, opts ...Option) *Userli {
113128
}
114129

115130
func (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)
135152
func (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

151169
func (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

171191
func (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

Comments
 (0)