From bf1e6d727b737fceca2c0a7b4d5de459c08cf600 Mon Sep 17 00:00:00 2001 From: Phil Dibowitz Date: Thu, 12 Mar 2026 17:09:34 -0700 Subject: [PATCH 1/2] Add per-domain SMTP routing Currently, you can add multiple SMTP servers, but they will be round-robin'd. It is useful to be able to pick an SMTP server based on the `from` of the campaign. For example, Mailgun will DKIM sign based on the domain of the authenticating user, so if you send campains from more than one domain, you need to have different SMTP servers setup for each domain. This adds an optional list of domains that each SMTP server should be used with. If multiple servers specify the domain in question, we will pick one at random, just as the logic today does. If no domains are specified, or non match, then we fall back to the existing behavior of picking one at random. Signed-off-by: Phil Dibowitz --- cmd/settings.go | 18 +++++++ frontend/src/views/Settings.vue | 12 +++++ frontend/src/views/settings/smtp.vue | 8 +++ i18n/en.json | 2 + internal/messenger/email/email.go | 79 ++++++++++++++++++++++++---- models/settings.go | 35 ++++++------ 6 files changed, 128 insertions(+), 26 deletions(-) diff --git a/cmd/settings.go b/cmd/settings.go index d1441a614..54325676c 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -151,6 +151,24 @@ func (a *App) UpdateSettings(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.errorNoSMTP")) } + // Normalize from_addresses (duplicates across servers are allowed for round-robin). + for i, s := range set.SMTP { + if !s.Enabled { + continue + } + + // Normalize from addresses. + normalized := make([]string, 0, len(s.FromAddresses)) + for _, addr := range s.FromAddresses { + addr = strings.ToLower(strings.TrimSpace(addr)) + if addr == "" { + continue + } + normalized = append(normalized, addr) + } + set.SMTP[i].FromAddresses = normalized + } + // Always remove the trailing slash from the app root URL. set.AppRootURL = strings.TrimRight(set.AppRootURL, "/") diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index effd68353..82cb2abf1 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -127,6 +127,16 @@ export default Vue.extend({ } else { form.smtp[i].email_headers = []; } + + // Convert comma-separated from_addresses string to array. + if (form.smtp[i].strFromAddresses) { + form.smtp[i].from_addresses = form.smtp[i].strFromAddresses + .split(',') + .map((v) => v.trim()) + .filter((v) => v !== ''); + } else { + form.smtp[i].from_addresses = []; + } } // Bounces boxes. @@ -228,6 +238,8 @@ export default Vue.extend({ // Serialize the `email_headers` array map to display on the form. for (let i = 0; i < d.smtp.length; i += 1) { d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4); + // Convert from_addresses array to comma-separated string. + d.smtp[i].strFromAddresses = (d.smtp[i].from_addresses || []).join(', '); } // Domain blocklist array to multi-line string. diff --git a/frontend/src/views/settings/smtp.vue b/frontend/src/views/settings/smtp.vue index 3aaf1f803..f4b8b7da3 100644 --- a/frontend/src/views/settings/smtp.vue +++ b/frontend/src/views/settings/smtp.vue @@ -161,6 +161,13 @@ +
+ + + +
@@ -288,6 +295,7 @@ export default Vue.extend({ username: '', password: '', email_headers: [], + from_addresses: [], max_conns: 10, max_msg_retries: 2, msg_retry_delay: '0s', diff --git a/i18n/en.json b/i18n/en.json index 40e0146a5..6c1f54e98 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -563,6 +563,8 @@ "settings.smtp.customHeaders": "Custom headers", "settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.enabled": "Enabled", + "settings.smtp.fromAddresses": "From addresses / domains", + "settings.smtp.fromAddressesHelp": "Optional comma-separated list of email addresses or domains. Supports full emails (user@example.com), domains with @ (@example.com), or domains without @ (example.com). Emails from these addresses/domains will be routed through this SMTP server. Multiple servers can specify the same address/domain for round-robin load balancing.", "settings.smtp.heloHost": "HELO hostname", "settings.smtp.heloHostHelp": "Optional. Some SMTP servers require a FQDN in the hostname. By default, HELLOs go with `localhost`. Set this if a custom hostname should be used.", "settings.smtp.name": "SMTP", diff --git a/internal/messenger/email/email.go b/internal/messenger/email/email.go index dbbcbd2d0..e4b3a91e9 100644 --- a/internal/messenger/email/email.go +++ b/internal/messenger/email/email.go @@ -33,6 +33,7 @@ type Server struct { TLSType string `json:"tls_type"` TLSSkipVerify bool `json:"tls_skip_verify"` EmailHeaders map[string]string `json:"email_headers"` + FromAddresses []string `json:"from_addresses"` // Rest of the options are embedded directly from the smtppool lib. // The JSON tag is for config unmarshal to work. @@ -44,8 +45,9 @@ type Server struct { // Emailer is the SMTP e-mail messenger. type Emailer struct { - servers []*Server - name string + servers []*Server + name string + fromAddrToSrv map[string][]*Server } // New returns an SMTP e-mail Messenger backend with the given SMTP servers. @@ -53,8 +55,9 @@ type Emailer struct { // that are used as a round-robin pool, or a single server. func New(name string, servers ...Server) (*Emailer, error) { e := &Emailer{ - servers: make([]*Server, 0, len(servers)), - name: name, + servers: make([]*Server, 0, len(servers)), + name: name, + fromAddrToSrv: make(map[string][]*Server), } for _, srv := range servers { @@ -100,6 +103,22 @@ func New(name string, servers ...Server) (*Emailer, error) { s.pool = pool e.servers = append(e.servers, &s) + + // Map from addresses to this server. + // Track which keys are already mapped to this server to avoid duplicates. + seenForServer := make(map[string]bool) + for _, addr := range s.FromAddresses { + key := strings.ToLower(strings.TrimSpace(addr)) + // Skip empty keys and duplicates for this server. + if key == "" { + continue + } + if seenForServer[key] { + continue + } + seenForServer[key] = true + e.fromAddrToSrv[key] = append(e.fromAddrToSrv[key], &s) + } } return e, nil @@ -112,16 +131,58 @@ func (e *Emailer) Name() string { // Push pushes a message to the server. func (e *Emailer) Push(m models.Message) error { - // If there are more than one SMTP servers, send to a random - // one from the list. + // Check if there's a specific SMTP server mapped to the from address. var ( ln = len(e.servers) srv *Server ) - if ln > 1 { - srv = e.servers[rand.Intn(ln)] + + // Extract the email address from the From field using RFC 5322 parsing. + fromAddr := m.From + if addr, err := mail.ParseAddress(m.From); err == nil { + fromAddr = addr.Address + } + // If parsing fails, fall back to the raw From value (for backward compatibility). + fromAddr = strings.ToLower(strings.TrimSpace(fromAddr)) + + // Check if there's a server mapped to this from address. + // First try exact match, then try domain match. + var matchedServers []*Server + if servers, ok := e.fromAddrToSrv[fromAddr]; ok { + matchedServers = servers } else { - srv = e.servers[0] + // Extract domain from email address for domain matching. + if atIdx := strings.Index(fromAddr, "@"); atIdx >= 0 { + domain := fromAddr[atIdx:] // includes @ symbol + if servers, ok := e.fromAddrToSrv[domain]; ok { + matchedServers = servers + } else { + // Try without @ symbol. + domainOnly := fromAddr[atIdx+1:] + if servers, ok := e.fromAddrToSrv[domainOnly]; ok { + matchedServers = servers + } + } + } + } + + // If we found matching servers, pick one randomly (round-robin). + if len(matchedServers) > 0 { + if len(matchedServers) > 1 { + srv = matchedServers[rand.Intn(len(matchedServers))] + } else { + srv = matchedServers[0] + } + } + + // If no match found, fall back to load balancing. + if srv == nil { + if ln > 1 { + // If there are more than one SMTP servers, send to a random one from the list. + srv = e.servers[rand.Intn(ln)] + } else { + srv = e.servers[0] + } } // Are there attachments? diff --git a/models/settings.go b/models/settings.go index 14c587588..74915308b 100644 --- a/models/settings.go +++ b/models/settings.go @@ -81,23 +81,24 @@ type Settings struct { UploadS3Expiry string `json:"upload.s3.expiry"` SMTP []struct { - Name string `json:"name"` - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Host string `json:"host"` - HelloHostname string `json:"hello_hostname"` - Port int `json:"port"` - AuthProtocol string `json:"auth_protocol"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - EmailHeaders []map[string]string `json:"email_headers"` - MaxConns int `json:"max_conns"` - MaxMsgRetries int `json:"max_msg_retries"` - MsgRetryDelay string `json:"msg_retry_delay"` - IdleTimeout string `json:"idle_timeout"` - WaitTimeout string `json:"wait_timeout"` - TLSType string `json:"tls_type"` - TLSSkipVerify bool `json:"tls_skip_verify"` + Name string `json:"name"` + UUID string `json:"uuid"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + HelloHostname string `json:"hello_hostname"` + Port int `json:"port"` + AuthProtocol string `json:"auth_protocol"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + EmailHeaders []map[string]string `json:"email_headers"` + MaxConns int `json:"max_conns"` + MaxMsgRetries int `json:"max_msg_retries"` + MsgRetryDelay string `json:"msg_retry_delay"` + IdleTimeout string `json:"idle_timeout"` + WaitTimeout string `json:"wait_timeout"` + TLSType string `json:"tls_type"` + TLSSkipVerify bool `json:"tls_skip_verify"` + FromAddresses []string `json:"from_addresses"` } `json:"smtp"` Messengers []struct { From a6e1859e83efccb52956e46531c05e3c52a46dc6 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Wed, 29 Apr 2026 23:20:47 +0530 Subject: [PATCH 2/2] Simplify and clean up e-mail based routing logic. - Clean up UI to use a taginput instead of comma separated strings. - Drop support for superfluous representation '@domain.com'. Only emails, and FQDNs like domain.com are supported now.# On branch email-routing - Refactor and simplify overall logic.# Changes to be committed: --- cmd/settings.go | 15 ++-- frontend/src/views/Settings.vue | 12 --- frontend/src/views/settings/smtp.vue | 9 ++- i18n/en.json | 2 +- internal/messenger/email/email.go | 117 +++++++++++---------------- internal/subimporter/importer.go | 22 +++-- internal/utils/utils.go | 43 +++++++--- 7 files changed, 104 insertions(+), 116 deletions(-) diff --git a/cmd/settings.go b/cmd/settings.go index 54325676c..ced9a33de 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -151,22 +151,21 @@ func (a *App) UpdateSettings(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("settings.errorNoSMTP")) } - // Normalize from_addresses (duplicates across servers are allowed for round-robin). + // Normalize `from_addresses``. Values are either an e-mail address + // or an FQDN. Duplicate domains across server blocks are allowed + // (they get round-robin'd while sending). for i, s := range set.SMTP { if !s.Enabled { continue } - // Normalize from addresses. - normalized := make([]string, 0, len(s.FromAddresses)) + addrs := make([]string, 0, len(s.FromAddresses)) for _, addr := range s.FromAddresses { - addr = strings.ToLower(strings.TrimSpace(addr)) - if addr == "" { - continue + if k := email.NormalizeAddr(addr); k != "" { + addrs = append(addrs, k) } - normalized = append(normalized, addr) } - set.SMTP[i].FromAddresses = normalized + set.SMTP[i].FromAddresses = addrs } // Always remove the trailing slash from the app root URL. diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 82cb2abf1..effd68353 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -127,16 +127,6 @@ export default Vue.extend({ } else { form.smtp[i].email_headers = []; } - - // Convert comma-separated from_addresses string to array. - if (form.smtp[i].strFromAddresses) { - form.smtp[i].from_addresses = form.smtp[i].strFromAddresses - .split(',') - .map((v) => v.trim()) - .filter((v) => v !== ''); - } else { - form.smtp[i].from_addresses = []; - } } // Bounces boxes. @@ -238,8 +228,6 @@ export default Vue.extend({ // Serialize the `email_headers` array map to display on the form. for (let i = 0; i < d.smtp.length; i += 1) { d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4); - // Convert from_addresses array to comma-separated string. - d.smtp[i].strFromAddresses = (d.smtp[i].from_addresses || []).join(', '); } // Domain blocklist array to multi-line string. diff --git a/frontend/src/views/settings/smtp.vue b/frontend/src/views/settings/smtp.vue index f4b8b7da3..b3edc37fa 100644 --- a/frontend/src/views/settings/smtp.vue +++ b/frontend/src/views/settings/smtp.vue @@ -164,8 +164,8 @@
- +
@@ -371,6 +371,11 @@ export default Vue.extend({ return true; }, + validateFromAddress(v) { + // Accept an e-mail address (user@example.com) or a domain (example.com). + return /^[^\s@]+(\.[^\s@]+)+$|^[^\s@]+@[^\s@]+(\.[^\s@]+)+$/.test(v); + }, + fillSettings(n, key) { this.data.smtp.splice(n, 1, { ...this.data.smtp[n], diff --git a/i18n/en.json b/i18n/en.json index 6c1f54e98..ffe3135b6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -564,7 +564,7 @@ "settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.enabled": "Enabled", "settings.smtp.fromAddresses": "From addresses / domains", - "settings.smtp.fromAddressesHelp": "Optional comma-separated list of email addresses or domains. Supports full emails (user@example.com), domains with @ (@example.com), or domains without @ (example.com). Emails from these addresses/domains will be routed through this SMTP server. Multiple servers can specify the same address/domain for round-robin load balancing.", + "settings.smtp.fromAddressesHelp": "Optional list of e-mail addresses (user@example.com) or domains (example.com) to route through this SMTP server. If multiple servers specify the same entry, they are used round robin.", "settings.smtp.heloHost": "HELO hostname", "settings.smtp.heloHostHelp": "Optional. Some SMTP servers require a FQDN in the hostname. By default, HELLOs go with `localhost`. Set this if a custom hostname should be used.", "settings.smtp.name": "SMTP", diff --git a/internal/messenger/email/email.go b/internal/messenger/email/email.go index e4b3a91e9..9b3f63988 100644 --- a/internal/messenger/email/email.go +++ b/internal/messenger/email/email.go @@ -45,9 +45,17 @@ type Server struct { // Emailer is the SMTP e-mail messenger. type Emailer struct { - servers []*Server - name string - fromAddrToSrv map[string][]*Server + name string + + // pools holds groups of SMTP servers indexed by a key ('from'-address + // or a domain set per SMTPs server). An empty key holds all servers + // and is the fallback round-robin when there's no match (old behaviour). + pools map[string][]*Server +} + +// NormalizeAddr normalizes an e-mail address (strip spaces, lowercase). +func NormalizeAddr(s string) string { + return strings.ToLower(strings.TrimSpace(s)) } // New returns an SMTP e-mail Messenger backend with the given SMTP servers. @@ -55,9 +63,8 @@ type Emailer struct { // that are used as a round-robin pool, or a single server. func New(name string, servers ...Server) (*Emailer, error) { e := &Emailer{ - servers: make([]*Server, 0, len(servers)), - name: name, - fromAddrToSrv: make(map[string][]*Server), + name: name, + pools: make(map[string][]*Server), } for _, srv := range servers { @@ -102,22 +109,14 @@ func New(name string, servers ...Server) (*Emailer, error) { } s.pool = pool - e.servers = append(e.servers, &s) - // Map from addresses to this server. - // Track which keys are already mapped to this server to avoid duplicates. - seenForServer := make(map[string]bool) + // Add to the global list (empty key) and to each from-address + // bucket. Duplicate keys across servers are fine and get round-robin'd. + e.pools[""] = append(e.pools[""], &s) for _, addr := range s.FromAddresses { - key := strings.ToLower(strings.TrimSpace(addr)) - // Skip empty keys and duplicates for this server. - if key == "" { - continue + if key := NormalizeAddr(addr); key != "" { + e.pools[key] = append(e.pools[key], &s) } - if seenForServer[key] { - continue - } - seenForServer[key] = true - e.fromAddrToSrv[key] = append(e.fromAddrToSrv[key], &s) } } @@ -131,59 +130,15 @@ func (e *Emailer) Name() string { // Push pushes a message to the server. func (e *Emailer) Push(m models.Message) error { - // Check if there's a specific SMTP server mapped to the from address. - var ( - ln = len(e.servers) - srv *Server - ) - - // Extract the email address from the From field using RFC 5322 parsing. - fromAddr := m.From - if addr, err := mail.ParseAddress(m.From); err == nil { - fromAddr = addr.Address - } - // If parsing fails, fall back to the raw From value (for backward compatibility). - fromAddr = strings.ToLower(strings.TrimSpace(fromAddr)) - - // Check if there's a server mapped to this from address. - // First try exact match, then try domain match. - var matchedServers []*Server - if servers, ok := e.fromAddrToSrv[fromAddr]; ok { - matchedServers = servers - } else { - // Extract domain from email address for domain matching. - if atIdx := strings.Index(fromAddr, "@"); atIdx >= 0 { - domain := fromAddr[atIdx:] // includes @ symbol - if servers, ok := e.fromAddrToSrv[domain]; ok { - matchedServers = servers - } else { - // Try without @ symbol. - domainOnly := fromAddr[atIdx+1:] - if servers, ok := e.fromAddrToSrv[domainOnly]; ok { - matchedServers = servers - } - } - } - } - - // If we found matching servers, pick one randomly (round-robin). - if len(matchedServers) > 0 { - if len(matchedServers) > 1 { - srv = matchedServers[rand.Intn(len(matchedServers))] - } else { - srv = matchedServers[0] - } - } - - // If no match found, fall back to load balancing. - if srv == nil { - if ln > 1 { - // If there are more than one SMTP servers, send to a random one from the list. - srv = e.servers[rand.Intn(ln)] - } else { - srv = e.servers[0] + // Pick the from-address-routed pool if there is one, else default + // to the full pool (empty key) for roundrobin. + pool := e.pools[""] + if len(e.pools) > 1 { + if srvs := e.getPool(m.From); srvs != nil { + pool = srvs } } + srv := pool[rand.Intn(len(pool))] // Are there attachments? var files []smtppool.Attachment @@ -274,8 +229,28 @@ func (e *Emailer) Flush() error { // Close closes the SMTP pools. func (e *Emailer) Close() error { - for _, s := range e.servers { + for _, s := range e.pools[""] { s.pool.Close() } return nil } + +// getPool returns the pool of servers configured to handle the given From +// header, matched by full e-mail and then by domain. +// Returns nil if no mapping matches. +func (e *Emailer) getPool(from string) []*Server { + addr := utils.ParseEmailAddress(from) + if addr == "" { + return nil + } + + if srvs, ok := e.pools[addr]; ok { + return srvs + } + + if _, after, ok := strings.Cut(addr, "@"); ok { + return e.pools[after] + } + + return nil +} diff --git a/internal/subimporter/importer.go b/internal/subimporter/importer.go index f318bc39c..88059ebaf 100644 --- a/internal/subimporter/importer.go +++ b/internal/subimporter/importer.go @@ -16,7 +16,6 @@ import ( "fmt" "io" "log" - "net/mail" "os" "regexp" "strings" @@ -24,6 +23,7 @@ import ( "github.com/gofrs/uuid/v5" "github.com/knadh/listmonk/internal/i18n" + "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/lib/pq" "golang.org/x/text/cases" @@ -601,25 +601,21 @@ func (im *Importer) Stop() { } } -// SanitizeEmail validates and sanitizes an e-mail string and returns the lowercased, -// e-mail component of an e-mail string. +// SanitizeEmail validates and sanitizes an e-mail string and returns the +// canonical (lowercased, trimmed) address. Domain allowlist/blocklist rules +// are enforced on top of the bare-address validation in utils.SanitizeEmail. func (im *Importer) SanitizeEmail(email string) (string, error) { - email = strings.ToLower(strings.TrimSpace(email)) - - // Since `mail.ParseAddress` parses an email address which can also contain optional name component - // here we check if incoming email string is same as the parsed email.Address. So this eliminates - // any valid email address with name and also valid address with empty name like ``. - em, err := mail.ParseAddress(email) - if err != nil || em.Address != email { + addr, err := utils.SanitizeEmail(email) + if err != nil { return "", errors.New(im.i18n.T("subscribers.invalidEmail")) } // Check if the e-mail's domain is blocklisted. The e-mail domain and blocklist config // are always lowercase. if im.hasAllowlist || im.hasBlocklist { - d := strings.Split(em.Address, "@") + d := strings.Split(addr, "@") if len(d) != 2 { - return em.Address, nil + return addr, nil } domain := d[1] @@ -636,7 +632,7 @@ func (im *Importer) SanitizeEmail(email string) (string, error) { } } - return em.Address, nil + return addr, nil } // ValidateFields validates incoming subscriber field values and returns sanitized fields. diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 60c1f9ee9..44aa51755 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -2,23 +2,48 @@ package utils import ( "crypto/rand" + "errors" "net/mail" "net/url" "path" "strings" ) -// ValidateEmail validates whether the given string is a correctly formed e-mail address. -func ValidateEmail(email string) bool { - // Since `mail.ParseAddress` parses an email address which can also contain an optional name component, - // here we check if incoming email string is same as the parsed email.Address. So this eliminates - // any valid email address with name and also valid address with empty name like ``. - em, err := mail.ParseAddress(email) - if err != nil || em.Address != email { - return false +// ErrInvalidEmail is returned by SanitizeEmail for malformed input. +var ErrInvalidEmail = errors.New("invalid e-mail address") + +// ValidateEmail reports whether s is a correctly formed bare e-mail address +// (no display name component). +func ValidateEmail(s string) bool { + _, err := SanitizeEmail(s) + return err == nil +} + +// SanitizeEmail trims, lowercases, and validates s as a bare e-mail address +// (no display name) and returns the canonical form. Returns ErrInvalidEmail +// for anything `mail.ParseAddress` rejects or for input with a display name. +func SanitizeEmail(s string) (string, error) { + s = strings.ToLower(strings.TrimSpace(s)) + em, err := mail.ParseAddress(s) + if err != nil || em.Address != s { + return "", ErrInvalidEmail } + return em.Address, nil +} - return true +// ParseEmailAddress extracts the lowercased bare address from an RFC 5322 +// "From"-style header value, accepting both bare addresses ("a@b.com") and +// the display-name form ("Name "). Returns "" if unparseable. +func ParseEmailAddress(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + em, err := mail.ParseAddress(s) + if err != nil { + return "" + } + return strings.ToLower(em.Address) } // GenerateRandomString generates a cryptographically random, alphanumeric string of length n.