From 9a8bb1134b1b358a600e313477e7ef54a5b411ec Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Fri, 7 Mar 2025 14:39:58 +0100 Subject: [PATCH] Allow multiple localpart catch all separators, e.g. both "+" and "-", for addresses you+anything@example.com and you-anything@example.com The original config option stays, and we still use it for the common case where we have a single separator. The "+" is configured by default. It is optional, just like the new option "LocalpartCatchallSeparators" (plural). When parsing the config file, we combine LocalpartCatchallSeparator and LocalpartCatchallSeparators into a single list LocalpartCatchallSeparatorsEffective, which we use throughout the code. For issue #301 by janc13 --- admin/admin.go | 12 ++-- config/config.go | 26 ++++---- config/doc.go | 8 +++ mox-/config.go | 41 ++++++++++-- mox-/lookup.go | 6 +- queue/hook.go | 4 +- smtpserver/server.go | 4 +- smtpserver/server_test.go | 10 ++- testdata/smtpservercatchall/domains.conf | 5 ++ testdata/webmail/domains.conf | 3 + webadmin/admin.go | 14 +++- webadmin/admin.js | 47 +++++++++++--- webadmin/admin.ts | 83 +++++++++++++++++------- webadmin/admin_test.go | 6 +- webadmin/api.json | 19 +++++- webadmin/api.ts | 10 +-- webapisrv/server.go | 7 +- webmail/api.go | 4 +- webmail/api.json | 3 +- webmail/api.ts | 4 +- webmail/api_test.go | 12 ++++ webmail/msg.js | 2 +- webmail/text.js | 2 +- webmail/view.go | 6 +- webmail/webmail.js | 7 +- webmail/webmail.ts | 5 +- 26 files changed, 255 insertions(+), 95 deletions(-) diff --git a/admin/admin.go b/admin/admin.go index 296d039..6484c0b 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -737,7 +737,7 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { } // checkAddressAvailable checks that the address after canonicalization is not -// already configured, and that its localpart does not contain the catchall +// already configured, and that its localpart does not contain a catchall // localpart separator. // // Must be called with config lock held. @@ -749,9 +749,13 @@ func checkAddressAvailable(addr smtp.Address) error { lp := mox.CanonicalLocalpart(addr.Localpart, dc) if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok { return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain)) - } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) { - return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator) - } else if _, ok := dc.Aliases[lp.String()]; ok { + } + for _, sep := range dc.LocalpartCatchallSeparatorsEffective { + if strings.Contains(string(addr.Localpart), sep) { + return fmt.Errorf("localpart cannot include domain catchall separator %s", sep) + } + } + if _, ok := dc.Aliases[lp.String()]; ok { return fmt.Errorf("address in use as alias") } return nil diff --git a/config/config.go b/config/config.go index 189bd96..b0d267e 100644 --- a/config/config.go +++ b/config/config.go @@ -279,17 +279,18 @@ type TransportDirect struct { } type Domain struct { - Disabled bool `sconf:"optional" sconf-doc:"Disabled domains can be useful during/before migrations. Domains that are disabled can still be configured like normal, including adding addresses using the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME certificates. TLS connections to host names involving the email domain will fail. A TLS certificate for the hostname (that wil be used as MX) itself will be requested. 2. Incoming deliveries over SMTP are rejected with a temporary error '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP using an (envelope) SMTP MAIL FROM address or message 'From' address of a disabled domain will be rejected with a temporary error '451 4.3.0 sender domain temporarily disabled'. Note that accounts with addresses at disabled domains can still log in and read email (unless the account itself is disabled)."` - Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` - ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."` - LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` - LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` - DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` - DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` - MTASTS *MTASTS `sconf:"optional" sconf-doc:"MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy."` - TLSRPT *TLSRPT `sconf:"optional" sconf-doc:"With TLSRPT a domain specifies in DNS where reports about encountered SMTP TLS behaviour should be sent. Useful for monitoring. Incoming TLS reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` - Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, these domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` - Aliases map[string]Alias `sconf:"optional" sconf-doc:"Aliases that cause messages to be delivered to one or more locally configured addresses. Keys are localparts (encoded, as they appear in email addresses)."` + Disabled bool `sconf:"optional" sconf-doc:"Disabled domains can be useful during/before migrations. Domains that are disabled can still be configured like normal, including adding addresses using the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME certificates. TLS connections to host names involving the email domain will fail. A TLS certificate for the hostname (that wil be used as MX) itself will be requested. 2. Incoming deliveries over SMTP are rejected with a temporary error '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP using an (envelope) SMTP MAIL FROM address or message 'From' address of a disabled domain will be rejected with a temporary error '451 4.3.0 sender domain temporarily disabled'. Note that accounts with addresses at disabled domains can still log in and read email (unless the account itself is disabled)."` + Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` + ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."` + LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` + LocalpartCatchallSeparators []string `sconf:"optional" sconf-doc:"Similar to LocalpartCatchallSeparator, but in case multiple are needed. For example both \"+\" and \"-\". Only of one LocalpartCatchallSeparator or LocalpartCatchallSeparators can be set. If set, the first separator is used to make unique addresses for outgoing SMTP connections with FromIDLoginAddresses."` + LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` + DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` + DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` + MTASTS *MTASTS `sconf:"optional" sconf-doc:"MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy."` + TLSRPT *TLSRPT `sconf:"optional" sconf-doc:"With TLSRPT a domain specifies in DNS where reports about encountered SMTP TLS behaviour should be sent. Useful for monitoring. Incoming TLS reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` + Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, these domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` + Aliases map[string]Alias `sconf:"optional" sconf-doc:"Aliases that cause messages to be delivered to one or more locally configured addresses. Keys are localparts (encoded, as they appear in email addresses)."` Domain dns.Domain `sconf:"-"` ClientSettingsDNSDomain dns.Domain `sconf:"-" json:"-"` @@ -297,7 +298,8 @@ type Domain struct { // Set when DMARC and TLSRPT (when set) has an address with different domain (we're // hosting the reporting), and there are no destination addresses configured for // the domain. Disables some functionality related to hosting a domain. - ReportsOnly bool `sconf:"-" json:"-"` + ReportsOnly bool `sconf:"-" json:"-"` + LocalpartCatchallSeparatorsEffective []string `sconf:"-"` // Either LocalpartCatchallSeparators, the value of LocalpartCatchallSeparator, or empty. } // todo: allow external addresses as members of aliases. we would add messages for them to the queue for outgoing delivery. we should require an admin addresses to which delivery failures will be delivered (locally, and to use in smtp mail from, so dsns go there). also take care to evaluate smtputf8 (if external address requires utf8 and incoming transaction didn't). diff --git a/config/doc.go b/config/doc.go index 0df0c65..44a5588 100644 --- a/config/doc.go +++ b/config/doc.go @@ -789,6 +789,14 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # delivered to you@example.com. (optional) LocalpartCatchallSeparator: + # Similar to LocalpartCatchallSeparator, but in case multiple are needed. For + # example both "+" and "-". Only of one LocalpartCatchallSeparator or + # LocalpartCatchallSeparators can be set. If set, the first separator is used to + # make unique addresses for outgoing SMTP connections with FromIDLoginAddresses. + # (optional) + LocalpartCatchallSeparators: + - + # If set, upper/lower case is relevant for email delivery. (optional) LocalpartCaseSensitive: false diff --git a/mox-/config.go b/mox-/config.go index 7757e67..461481b 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -1238,6 +1238,21 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, c.ClientSettingDomains[csd] = struct{}{} } + if domain.LocalpartCatchallSeparator != "" && len(domain.LocalpartCatchallSeparators) != 0 { + addDomainErrorf("cannot have both LocalpartCatchallSeparator and LocalpartCatchallSeparators") + } + domain.LocalpartCatchallSeparatorsEffective = domain.LocalpartCatchallSeparators + if domain.LocalpartCatchallSeparator != "" { + domain.LocalpartCatchallSeparatorsEffective = append(domain.LocalpartCatchallSeparatorsEffective, domain.LocalpartCatchallSeparator) + } + sepSeen := map[string]bool{} + for _, sep := range domain.LocalpartCatchallSeparatorsEffective { + if sepSeen[sep] { + addDomainErrorf("duplicate localpart catchall separator %q", sep) + } + sepSeen[sep] = true + } + for _, sign := range domain.DKIM.Sign { if _, ok := domain.DKIM.Selectors[sign]; !ok { addDomainErrorf("unknown selector %s for signing", sign) @@ -1434,7 +1449,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, dom, ok := c.Domains[a.Domain.Name()] if !ok { addAccountErrorf("unknown domain in fromid login address %q", s) - } else if dom.LocalpartCatchallSeparator == "" { + } else if len(dom.LocalpartCatchallSeparatorsEffective) == 0 { addAccountErrorf("localpart catchall separator not configured for domain for fromid login address %q", s) } acc.ParsedFromIDLoginAddresses[i] = a @@ -1660,9 +1675,14 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, dc := c.Domains[address.Domain.Name()] domainHasAddress[address.Domain.Name()] = true lp := CanonicalLocalpart(address.Localpart, dc) - if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) { - addDestErrorf("localpart of address %s includes domain catchall separator %s", address, dc.LocalpartCatchallSeparator) - } else { + var hasSep bool + for _, sep := range dc.LocalpartCatchallSeparatorsEffective { + if strings.Contains(string(address.Localpart), sep) { + hasSep = true + addDestErrorf("localpart of address %s includes domain catchall separator %s", address, sep) + } + } + if !hasSep { address.Localpart = lp } addrFull := address.Pack(true) @@ -1817,10 +1837,17 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, if err != nil { addAliasErrorf("parsing alias: %v", err) continue - } else if domain.LocalpartCatchallSeparator != "" && strings.Contains(string(lp), domain.LocalpartCatchallSeparator) { - addAliasErrorf("alias contains localpart catchall separator") - continue } else { + var hasSep bool + for _, sep := range domain.LocalpartCatchallSeparatorsEffective { + if strings.Contains(string(lp), sep) { + addAliasErrorf("alias contains localpart catchall separator") + hasSep = true + } + } + if hasSep { + continue + } clp = CanonicalLocalpart(lp, domain) } diff --git a/mox-/lookup.go b/mox-/lookup.go index 9ceca6a..4946627 100644 --- a/mox-/lookup.go +++ b/mox-/lookup.go @@ -86,10 +86,10 @@ func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, } // CanonicalLocalpart returns the canonical localpart, removing optional catchall -// separator, and optionally lower-casing the string. +// separators, and optionally lower-casing the string. func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) smtp.Localpart { - if d.LocalpartCatchallSeparator != "" { - t := strings.SplitN(string(localpart), d.LocalpartCatchallSeparator, 2) + for _, sep := range d.LocalpartCatchallSeparatorsEffective { + t := strings.SplitN(string(localpart), sep, 2) localpart = smtp.Localpart(t[0]) } diff --git a/queue/hook.go b/queue/hook.go index 94fb88d..b162bf5 100644 --- a/queue/hook.go +++ b/queue/hook.go @@ -650,8 +650,8 @@ func Incoming(ctx context.Context, log mlog.Log, acc *store.Account, messageID s log.Debugx("parsing recipient domain in incoming message", err) } else { domconf, _ := mox.Conf.Domain(dom) - if domconf.LocalpartCatchallSeparator != "" { - t := strings.SplitN(string(m.RcptToLocalpart), domconf.LocalpartCatchallSeparator, 2) + if len(domconf.LocalpartCatchallSeparatorsEffective) > 0 { + t := strings.SplitN(string(m.RcptToLocalpart), domconf.LocalpartCatchallSeparatorsEffective[0], 2) if len(t) == 2 { fromID = t[1] } diff --git a/smtpserver/server.go b/smtpserver/server.go index 6c487a5..1bcb605 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -2469,7 +2469,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr var genFromID bool if useFromID { // With submission, user can bring their own fromid. - t := strings.SplitN(string(c.mailFrom.Localpart), confDom.LocalpartCatchallSeparator, 2) + t := strings.SplitN(string(c.mailFrom.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2) localpartBase = t[0] if len(t) == 2 { fromID = t[1] @@ -2500,7 +2500,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr if genFromID { fromID = xrandomID(16) } - fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID) + fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromID) } // For multiple recipients, we don't make each message prefix unique, leaving out diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 17e7e7c..2878137 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -1494,12 +1494,13 @@ func TestCatchall(t *testing.T) { testDeliver("mjl@mox.example", nil) // Exact match. testDeliver("mjl+test@mox.example", nil) // Domain localpart catchall separator. testDeliver("MJL+TEST@mox.example", nil) // Again, and case insensitive. - testDeliver("unknown@mox.example", nil) // Catchall address, to account catchall. n, err := bstore.QueryDB[store.Message](ctxbg, ts.acc.DB).Count() tcheck(t, err, "checking delivered messages") tcompare(t, n, 3) + testDeliver("unknown@mox.example", nil) // Catchall address, to account catchall. + acc, err := store.OpenAccount(pkglog, "catchall", false) tcheck(t, err, "open account") defer func() { @@ -1509,6 +1510,13 @@ func TestCatchall(t *testing.T) { n, err = bstore.QueryDB[store.Message](ctxbg, acc.DB).Count() tcheck(t, err, "checking delivered messages to catchall account") tcompare(t, n, 1) + + testDeliver("mjl-test@mox2.example", nil) // Second catchall separator. + testDeliver("mjl-test+test@mox2.example", nil) // Silly, both separators in address. + testDeliver("mjl+test-test@mox2.example", nil) + n, err = bstore.QueryDB[store.Message](ctxbg, ts.acc.DB).Count() + tcheck(t, err, "checking delivered messages") + tcompare(t, n, 6) } // Test DKIM signing for outgoing messages. diff --git a/testdata/smtpservercatchall/domains.conf b/testdata/smtpservercatchall/domains.conf index 681d841..0891be4 100644 --- a/testdata/smtpservercatchall/domains.conf +++ b/testdata/smtpservercatchall/domains.conf @@ -1,11 +1,16 @@ Domains: mox.example: LocalpartCatchallSeparator: + + mox2.example: + LocalpartCatchallSeparators: + - + + - - Accounts: mjl: Domain: mox.example Destinations: mjl@mox.example: nil + mjl@mox2.example: nil catchall: Domain: mox.example Destinations: diff --git a/testdata/webmail/domains.conf b/testdata/webmail/domains.conf index 228c9c9..5a61119 100644 --- a/testdata/webmail/domains.conf +++ b/testdata/webmail/domains.conf @@ -2,6 +2,9 @@ Domains: disabled.example: Disabled: true mox.example: + LocalpartCatchallSeparators: + - + + - - DKIM: Selectors: testsel: diff --git a/webadmin/admin.go b/webadmin/admin.go index 6b92194..0d9f8d8 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -2527,9 +2527,19 @@ func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, cli // DomainLocalpartConfigSave saves the localpart catchall and case-sensitive // settings for a domain. -func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) { +func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName string, localpartCatchallSeparators []string, localpartCaseSensitive bool) { err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error { - domain.LocalpartCatchallSeparator = localpartCatchallSeparator + domain.LocalpartCatchallSeparatorsEffective = localpartCatchallSeparators + // If there is a single separator, we prefer the non-list form, it's easier to + // read/edit and should suffice for most setups. + domain.LocalpartCatchallSeparator = "" + domain.LocalpartCatchallSeparators = nil + if len(localpartCatchallSeparators) == 1 { + domain.LocalpartCatchallSeparator = localpartCatchallSeparators[0] + } else { + domain.LocalpartCatchallSeparators = localpartCatchallSeparators + } + domain.LocalpartCaseSensitive = localpartCaseSensitive return nil }) diff --git a/webadmin/admin.js b/webadmin/admin.js index a376088..5e34ecc 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -298,7 +298,7 @@ var api; "AutoconfCheckResult": { "Name": "AutoconfCheckResult", "Docs": "", "Fields": [{ "Name": "ClientSettingsDomainIPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, "AutodiscoverCheckResult": { "Name": "AutodiscoverCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "AutodiscoverSRV"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, "AutodiscoverSRV": { "Name": "AutodiscoverSRV", "Docs": "", "Fields": [{ "Name": "Target", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Priority", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Weight", "Docs": "", "Typewords": ["uint16"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }] }, - "ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Disabled", "Docs": "", "Typewords": ["bool"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["{}", "Alias"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Disabled", "Docs": "", "Typewords": ["bool"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparators", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["{}", "Alias"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "LocalpartCatchallSeparatorsEffective", "Docs": "", "Typewords": ["[]", "string"] }] }, "DKIM": { "Name": "DKIM", "Docs": "", "Fields": [{ "Name": "Selectors", "Docs": "", "Typewords": ["{}", "Selector"] }, { "Name": "Sign", "Docs": "", "Typewords": ["[]", "string"] }] }, "Selector": { "Name": "Selector", "Docs": "", "Fields": [{ "Name": "Hash", "Docs": "", "Typewords": ["string"] }, { "Name": "HashEffective", "Docs": "", "Typewords": ["string"] }, { "Name": "Canonicalization", "Docs": "", "Typewords": ["Canonicalization"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HeadersEffective", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "DontSealHeaders", "Docs": "", "Typewords": ["bool"] }, { "Name": "Expiration", "Docs": "", "Typewords": ["string"] }, { "Name": "PrivateKeyFile", "Docs": "", "Typewords": ["string"] }, { "Name": "Algorithm", "Docs": "", "Typewords": ["string"] }] }, "Canonicalization": { "Name": "Canonicalization", "Docs": "", "Fields": [{ "Name": "HeaderRelaxed", "Docs": "", "Typewords": ["bool"] }, { "Name": "BodyRelaxed", "Docs": "", "Typewords": ["bool"] }] }, @@ -1220,11 +1220,11 @@ var api; } // DomainLocalpartConfigSave saves the localpart catchall and case-sensitive // settings for a domain. - async DomainLocalpartConfigSave(domainName, localpartCatchallSeparator, localpartCaseSensitive) { + async DomainLocalpartConfigSave(domainName, localpartCatchallSeparators, localpartCaseSensitive) { const fn = "DomainLocalpartConfigSave"; - const paramTypes = [["string"], ["string"], ["bool"]]; + const paramTypes = [["string"], ["[]", "string"], ["bool"]]; const returnTypes = []; - const params = [domainName, localpartCatchallSeparator, localpartCaseSensitive]; + const params = [domainName, localpartCatchallSeparators, localpartCaseSensitive]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // DomainDMARCAddressSave saves the DMARC reporting address/processing @@ -2372,7 +2372,6 @@ const domain = async (d) => { let clientSettingsDomainFieldset; let clientSettingsDomain; let localpartFieldset; - let localpartCatchallSeparator; let localpartCaseSensitive; let dmarcFieldset; let dmarcLocalpart; @@ -2469,11 +2468,39 @@ const domain = async (d) => { e.preventDefault(); e.stopPropagation(); await check(clientSettingsDomainFieldset, client.DomainClientSettingsDomainSave(d, clientSettingsDomain.value)); - }, clientSettingsDomainFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name.'), dom.div('Client settings domain'), clientSettingsDomain = dom.input(attr.value(domainConfig.ClientSettingsDomain), style({ width: '30em' }))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.form(style({ marginTop: '1ex' }), async function submit(e) { - e.preventDefault(); - e.stopPropagation(); - await check(localpartFieldset, client.DomainLocalpartConfigSave(d, localpartCatchallSeparator.value, localpartCaseSensitive.checked)); - }, localpartFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('If set, upper/lower case is relevant for email delivery.'), dom.div('Localpart case sensitive'), localpartCaseSensitive = dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : [])), dom.label(attr.title('If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'), dom.div('Localpart catchall separator'), localpartCatchallSeparator = dom.input(attr.value(domainConfig.LocalpartCatchallSeparator))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('DMARC reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) { + }, clientSettingsDomainFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name.'), dom.div('Client settings domain'), clientSettingsDomain = dom.input(attr.value(domainConfig.ClientSettingsDomain), style({ width: '30em' }))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), (() => { + let separatorViews = []; + let separatorsBox; + const addSeparatorView = (s) => { + const separator = dom.input(attr.required(''), attr.value(s), style({ width: '2em' })); + const v = { + separator: separator, + root: dom.div(separator, ' ', dom.clickbutton('Remove', function click() { + separatorViews.splice(separatorViews.indexOf(v), 1); + v.root.remove(); + if (separatorViews.length === 0) { + separatorsBox.append(dom.div('(None)')); + } + })), + }; + if (separatorViews.length === 0) { + dom._kids(separatorsBox); + } + separatorViews.push(v); + separatorsBox.appendChild(v.root); + }; + const elem = dom.form(style({ marginTop: '1ex' }), async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + await check(localpartFieldset, client.DomainLocalpartConfigSave(d, separatorViews.map(v => v.separator.value), localpartCaseSensitive.checked)); + }, localpartFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('If set, upper/lower case is relevant for email delivery.'), dom.div('Localpart case sensitive'), localpartCaseSensitive = dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : [])), dom.div(dom.label(attr.title('If not empty, only the string before the separator is used for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'), 'Localpart catchall separators'), ' ', dom.clickbutton('Add', function click() { + addSeparatorView(''); + }), separatorsBox = dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.25em' }), dom.div('(None)'))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))); + for (const sep of (domainConfig.LocalpartCatchallSeparatorsEffective || [])) { + addSeparatorView(sep); + } + return elem; + })(), dom.br(), dom.h2('DMARC reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) { e.preventDefault(); e.stopPropagation(); if (!dmarcLocalpart.value) { diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 79078c6..949be9b 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -1243,7 +1243,6 @@ const domain = async (d: string) => { let clientSettingsDomain: HTMLInputElement let localpartFieldset: HTMLFieldSetElement - let localpartCatchallSeparator: HTMLInputElement let localpartCaseSensitive: HTMLInputElement let dmarcFieldset: HTMLFieldSetElement @@ -1588,28 +1587,68 @@ const domain = async (d: string) => { dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))), ), ), - dom.form( - style({marginTop: '1ex'}), - async function submit(e: SubmitEvent) { - e.preventDefault() - e.stopPropagation() - await check(localpartFieldset, client.DomainLocalpartConfigSave(d, localpartCatchallSeparator.value, localpartCaseSensitive.checked)) - }, - localpartFieldset=dom.fieldset( - style({display: 'flex', gap: '1em'}), - dom.label( - attr.title('If set, upper/lower case is relevant for email delivery.'), - dom.div('Localpart case sensitive'), - localpartCaseSensitive=dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : []), + (() => { + interface SeparatorView { + root: HTMLElement + separator: HTMLInputElement + } + let separatorViews: SeparatorView[] = [] + let separatorsBox: HTMLDivElement + + const addSeparatorView = (s: string) => { + const separator = dom.input(attr.required(''), attr.value(s), style({width: '2em'})) + const v = { + separator: separator, + root: dom.div( + separator, ' ', + dom.clickbutton('Remove', function click() { + separatorViews.splice(separatorViews.indexOf(v), 1) + v.root.remove() + if (separatorViews.length === 0) { + separatorsBox.append(dom.div('(None)')) + } + }), + ), + } + if (separatorViews.length === 0) { + dom._kids(separatorsBox) + } + separatorViews.push(v) + separatorsBox.appendChild(v.root) + } + + const elem = dom.form( + style({marginTop: '1ex'}), + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + await check(localpartFieldset, client.DomainLocalpartConfigSave(d, separatorViews.map(v => v.separator.value), localpartCaseSensitive.checked)) + }, + localpartFieldset=dom.fieldset( + style({display: 'flex', gap: '1em'}), + dom.label( + attr.title('If set, upper/lower case is relevant for email delivery.'), + dom.div('Localpart case sensitive'), + localpartCaseSensitive=dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : []), + ), + dom.div( + dom.label( + attr.title('If not empty, only the string before the separator is used for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'), + 'Localpart catchall separators', + ), ' ', + dom.clickbutton('Add', function click() { + addSeparatorView('') + }), + separatorsBox=dom.div(style({display: 'flex', flexDirection: 'column', gap: '.25em'}), dom.div('(None)')), + ), + dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))), ), - dom.label( - attr.title('If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'), - dom.div('Localpart catchall separator'), - localpartCatchallSeparator=dom.input(attr.value(domainConfig.LocalpartCatchallSeparator)), - ), - dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))), - ), - ), + ) + for (const sep of (domainConfig.LocalpartCatchallSeparatorsEffective || [])) { + addSeparatorView(sep) + } + return elem + })(), dom.br(), dom.h2('DMARC reporting address'), diff --git a/webadmin/admin_test.go b/webadmin/admin_test.go index 031ee2c..bcbeca9 100644 --- a/webadmin/admin_test.go +++ b/webadmin/admin_test.go @@ -277,9 +277,9 @@ func TestAdmin(t *testing.T) { tneedErrorCode(t, "user:error", func() { api.DomainClientSettingsDomainSave(ctxbg, "bogus.example", "unknown.example") }) api.DomainClientSettingsDomainSave(ctxbg, "mox.example", "") // Restore. - api.DomainLocalpartConfigSave(ctxbg, "mox.example", "-", true) - tneedErrorCode(t, "user:error", func() { api.DomainLocalpartConfigSave(ctxbg, "bogus.example", "", false) }) - api.DomainLocalpartConfigSave(ctxbg, "mox.example", "", false) // Restore. + api.DomainLocalpartConfigSave(ctxbg, "mox.example", []string{"-"}, true) + tneedErrorCode(t, "user:error", func() { api.DomainLocalpartConfigSave(ctxbg, "bogus.example", nil, false) }) + api.DomainLocalpartConfigSave(ctxbg, "mox.example", nil, false) // Restore. api.DomainDMARCAddressSave(ctxbg, "mox.example", "dmarc-reports", "", "mjl", "DMARC") tneedErrorCode(t, "user:error", func() { api.DomainDMARCAddressSave(ctxbg, "bogus.example", "dmarc-reports", "", "mjl", "DMARC") }) diff --git a/webadmin/api.json b/webadmin/api.json index baad283..32e107b 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -1710,8 +1710,9 @@ ] }, { - "Name": "localpartCatchallSeparator", + "Name": "localpartCatchallSeparators", "Typewords": [ + "[]", "string" ] }, @@ -3441,6 +3442,14 @@ "string" ] }, + { + "Name": "LocalpartCatchallSeparators", + "Docs": "", + "Typewords": [ + "[]", + "string" + ] + }, { "Name": "LocalpartCaseSensitive", "Docs": "", @@ -3501,6 +3510,14 @@ "Typewords": [ "Domain" ] + }, + { + "Name": "LocalpartCatchallSeparatorsEffective", + "Docs": "Either LocalpartCatchallSeparators, the value of LocalpartCatchallSeparator, or empty.", + "Typewords": [ + "[]", + "string" + ] } ] }, diff --git a/webadmin/api.ts b/webadmin/api.ts index 159b263..a2c8168 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -270,6 +270,7 @@ export interface ConfigDomain { Description: string ClientSettingsDomain: string LocalpartCatchallSeparator: string + LocalpartCatchallSeparators?: string[] | null LocalpartCaseSensitive: boolean DKIM: DKIM DMARC?: DMARC | null @@ -278,6 +279,7 @@ export interface ConfigDomain { Routes?: Route[] | null Aliases?: { [key: string]: Alias } Domain: Domain + LocalpartCatchallSeparatorsEffective?: string[] | null // Either LocalpartCatchallSeparators, the value of LocalpartCatchallSeparator, or empty. } export interface DKIM { @@ -1184,7 +1186,7 @@ export const types: TypenameMap = { "AutoconfCheckResult": {"Name":"AutoconfCheckResult","Docs":"","Fields":[{"Name":"ClientSettingsDomainIPs","Docs":"","Typewords":["[]","string"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, "AutodiscoverCheckResult": {"Name":"AutodiscoverCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","AutodiscoverSRV"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, "AutodiscoverSRV": {"Name":"AutodiscoverSRV","Docs":"","Fields":[{"Name":"Target","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["uint16"]},{"Name":"Priority","Docs":"","Typewords":["uint16"]},{"Name":"Weight","Docs":"","Typewords":["uint16"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]}]}, - "ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Disabled","Docs":"","Typewords":["bool"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"Aliases","Docs":"","Typewords":["{}","Alias"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, + "ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Disabled","Docs":"","Typewords":["bool"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparators","Docs":"","Typewords":["[]","string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"Aliases","Docs":"","Typewords":["{}","Alias"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]},{"Name":"LocalpartCatchallSeparatorsEffective","Docs":"","Typewords":["[]","string"]}]}, "DKIM": {"Name":"DKIM","Docs":"","Fields":[{"Name":"Selectors","Docs":"","Typewords":["{}","Selector"]},{"Name":"Sign","Docs":"","Typewords":["[]","string"]}]}, "Selector": {"Name":"Selector","Docs":"","Fields":[{"Name":"Hash","Docs":"","Typewords":["string"]},{"Name":"HashEffective","Docs":"","Typewords":["string"]},{"Name":"Canonicalization","Docs":"","Typewords":["Canonicalization"]},{"Name":"Headers","Docs":"","Typewords":["[]","string"]},{"Name":"HeadersEffective","Docs":"","Typewords":["[]","string"]},{"Name":"DontSealHeaders","Docs":"","Typewords":["bool"]},{"Name":"Expiration","Docs":"","Typewords":["string"]},{"Name":"PrivateKeyFile","Docs":"","Typewords":["string"]},{"Name":"Algorithm","Docs":"","Typewords":["string"]}]}, "Canonicalization": {"Name":"Canonicalization","Docs":"","Fields":[{"Name":"HeaderRelaxed","Docs":"","Typewords":["bool"]},{"Name":"BodyRelaxed","Docs":"","Typewords":["bool"]}]}, @@ -2194,11 +2196,11 @@ export class Client { // DomainLocalpartConfigSave saves the localpart catchall and case-sensitive // settings for a domain. - async DomainLocalpartConfigSave(domainName: string, localpartCatchallSeparator: string, localpartCaseSensitive: boolean): Promise { + async DomainLocalpartConfigSave(domainName: string, localpartCatchallSeparators: string[] | null, localpartCaseSensitive: boolean): Promise { const fn: string = "DomainLocalpartConfigSave" - const paramTypes: string[][] = [["string"],["string"],["bool"]] + const paramTypes: string[][] = [["string"],["[]","string"],["bool"]] const returnTypes: string[][] = [] - const params: any[] = [domainName, localpartCatchallSeparator, localpartCaseSensitive] + const params: any[] = [domainName, localpartCatchallSeparators, localpartCaseSensitive] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } diff --git a/webapisrv/server.go b/webapisrv/server.go index cacd852..2f49c29 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -1007,10 +1007,7 @@ func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.S useFromID := slices.Contains(accConf.ParsedFromIDLoginAddresses, loginAddr) var localpartBase string if useFromID { - if confDom.LocalpartCatchallSeparator == "" { - xcheckuserf(errors.New(`localpart catchall separator must be configured for domain`), `composing unique "from" address`) - } - localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0] + localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0] } fromIDs := make([]string, len(recipients)) qml := make([]queue.Msg, len(recipients)) @@ -1019,7 +1016,7 @@ func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.S fp := fromPath if useFromID { fromIDs[i] = xrandomID(16) - fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromIDs[i]) + fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromIDs[i]) } // Don't use per-recipient unique message prefix when multiple recipients are diff --git a/webmail/api.go b/webmail/api.go index 61d30ac..82f854a 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -956,7 +956,7 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { fromPath := fromAddr.Address.Path() var localpartBase string if useFromID { - localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparator, 2)[0] + localpartBase = strings.SplitN(string(fromPath.Localpart), confDom.LocalpartCatchallSeparatorsEffective[0], 2)[0] } qml := make([]queue.Msg, len(recipients)) now := time.Now() @@ -965,7 +965,7 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { var fromID string if useFromID { fromID = xrandomID(ctx, 16) - fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparator + fromID) + fp.Localpart = smtp.Localpart(localpartBase + confDom.LocalpartCatchallSeparatorsEffective[0] + fromID) } // Don't use per-recipient unique message prefix when multiple recipients are diff --git a/webmail/api.json b/webmail/api.json index 09ccb04..abf8f74 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -2002,9 +2002,10 @@ "Docs": "DomainAddressConfig has the address (localpart) configuration for a domain, so\nthe webmail client can decide if an address matches the addresses of the\naccount.", "Fields": [ { - "Name": "LocalpartCatchallSeparator", + "Name": "LocalpartCatchallSeparators", "Docs": "Can be empty.", "Typewords": [ + "[]", "string" ] }, diff --git a/webmail/api.ts b/webmail/api.ts index 5724faf..6f0d185 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -264,7 +264,7 @@ export interface EventStart { // the webmail client can decide if an address matches the addresses of the // account. export interface DomainAddressConfig { - LocalpartCatchallSeparator: string // Can be empty. + LocalpartCatchallSeparators?: string[] | null // Can be empty. LocalpartCaseSensitive: boolean } @@ -621,7 +621,7 @@ export const types: TypenameMap = { "Settings": {"Name":"Settings","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["uint8"]},{"Name":"Signature","Docs":"","Typewords":["string"]},{"Name":"Quoting","Docs":"","Typewords":["Quoting"]},{"Name":"ShowAddressSecurity","Docs":"","Typewords":["bool"]},{"Name":"ShowHTML","Docs":"","Typewords":["bool"]},{"Name":"NoShowShortcuts","Docs":"","Typewords":["bool"]},{"Name":"ShowHeaders","Docs":"","Typewords":["[]","string"]}]}, "Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]}, "EventStart": {"Name":"EventStart","Docs":"","Fields":[{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"LoginAddress","Docs":"","Typewords":["MessageAddress"]},{"Name":"Addresses","Docs":"","Typewords":["[]","MessageAddress"]},{"Name":"DomainAddressConfigs","Docs":"","Typewords":["{}","DomainAddressConfig"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Mailboxes","Docs":"","Typewords":["[]","Mailbox"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"Settings","Docs":"","Typewords":["Settings"]},{"Name":"AccountPath","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]}]}, - "DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]}, + "DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparators","Docs":"","Typewords":["[]","string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]}, "EventViewErr": {"Name":"EventViewErr","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]},{"Name":"Err","Docs":"","Typewords":["string"]}]}, "EventViewReset": {"Name":"EventViewReset","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]}]}, "EventViewMsgs": {"Name":"EventViewMsgs","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]},{"Name":"MessageItems","Docs":"","Typewords":["[]","[]","MessageItem"]},{"Name":"ParsedMessage","Docs":"","Typewords":["nullable","ParsedMessage"]},{"Name":"ViewEnd","Docs":"","Typewords":["bool"]}]}, diff --git a/webmail/api_test.go b/webmail/api_test.go index 662298a..7276905 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -355,6 +355,18 @@ func TestAPI(t *testing.T) { }) // todo: check delivery of 6 messages to inbox, 1 to sent + api.MessageSubmit(ctx, SubmitMessage{ + From: "mjl-altcatchall@mox.example", + To: []string{"mjl-to@mox.example", "mjl to2 "}, + Cc: []string{"mjl-cc@mox.example", "mjl cc2 "}, + Bcc: []string{"mjl-bcc@mox.example", "mjl bcc2 "}, + Subject: "test email", + TextBody: "this is the content\n\ncheers,\nmox", + ReplyTo: "mjl replyto ", + UserAgent: "moxwebmail/dev", + }) + // todo: check delivery of 6 messages to inbox, 1 to sent + // Reply with attachments. api.MessageSubmit(ctx, SubmitMessage{ From: "mjl@mox.example", diff --git a/webmail/msg.js b/webmail/msg.js index 3a165aa..f6fda85 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -314,7 +314,7 @@ var api; "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, - "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparators", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, diff --git a/webmail/text.js b/webmail/text.js index 69fb530..53fe8ee 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -314,7 +314,7 @@ var api; "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, - "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparators", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, diff --git a/webmail/view.go b/webmail/view.go index 513a154..3fdb25c 100644 --- a/webmail/view.go +++ b/webmail/view.go @@ -228,8 +228,8 @@ type EventStart struct { // the webmail client can decide if an address matches the addresses of the // account. type DomainAddressConfig struct { - LocalpartCatchallSeparator string // Can be empty. - LocalpartCaseSensitive bool + LocalpartCatchallSeparators []string // Can be empty. + LocalpartCaseSensitive bool } // EventViewMsgs contains messages for a view, possibly a continuation of an @@ -764,7 +764,7 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R domainAddressConfigs := map[string]DomainAddressConfig{} for _, a := range addresses { dom, _ := mox.Conf.Domain(a.Domain) - domainAddressConfigs[a.Domain.ASCII] = DomainAddressConfig{dom.LocalpartCatchallSeparator, dom.LocalpartCaseSensitive} + domainAddressConfigs[a.Domain.ASCII] = DomainAddressConfig{dom.LocalpartCatchallSeparatorsEffective, dom.LocalpartCaseSensitive} } // Write first event, allowing client to fill its UI with mailboxes. diff --git a/webmail/webmail.js b/webmail/webmail.js index 0110bac..44606f4 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -314,7 +314,7 @@ var api; "Settings": { "Name": "Settings", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["uint8"] }, { "Name": "Signature", "Docs": "", "Typewords": ["string"] }, { "Name": "Quoting", "Docs": "", "Typewords": ["Quoting"] }, { "Name": "ShowAddressSecurity", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHTML", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoShowShortcuts", "Docs": "", "Typewords": ["bool"] }, { "Name": "ShowHeaders", "Docs": "", "Typewords": ["[]", "string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Settings", "Docs": "", "Typewords": ["Settings"] }, { "Name": "AccountPath", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }] }, - "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, + "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparators", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewReset": { "Name": "EventViewReset", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }] }, "EventViewMsgs": { "Name": "EventViewMsgs", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageItems", "Docs": "", "Typewords": ["[]", "[]", "MessageItem"] }, { "Name": "ParsedMessage", "Docs": "", "Typewords": ["nullable", "ParsedMessage"] }, { "Name": "ViewEnd", "Docs": "", "Typewords": ["bool"] }] }, @@ -2991,9 +2991,8 @@ const compose = (opts, listMailboxes) => { const normalizeUser = (a) => { let user = a.User; const domconf = domainAddressConfigs[a.Domain.ASCII]; - const localpartCatchallSeparator = domconf.LocalpartCatchallSeparator; - if (localpartCatchallSeparator) { - user = user.split(localpartCatchallSeparator)[0]; + for (const sep of (domconf.LocalpartCatchallSeparators || [])) { + user = user.split(sep)[0]; } const localpartCaseSensitive = domconf.LocalpartCaseSensitive; if (!localpartCaseSensitive) { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 1fa9757..3378078 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -1887,9 +1887,8 @@ const compose = (opts: ComposeOptions, listMailboxes: listMailboxes) => { const normalizeUser = (a: api.MessageAddress) => { let user = a.User const domconf = domainAddressConfigs[a.Domain.ASCII] - const localpartCatchallSeparator = domconf.LocalpartCatchallSeparator - if (localpartCatchallSeparator) { - user = user.split(localpartCatchallSeparator)[0] + for (const sep of (domconf.LocalpartCatchallSeparators || [])) { + user = user.split(sep)[0] } const localpartCaseSensitive = domconf.LocalpartCaseSensitive if (!localpartCaseSensitive) {