From b571dd4b28ec5bf0bf3a0dc8dd4fc31859c2d35f Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Wed, 29 Mar 2023 21:11:43 +0200 Subject: [PATCH] implement a catchall address for a domain by specifying a "destination" in an account that is just "@" followed by the domain, e.g. "@example.org". messages are only delivered to the catchall address when no regular destination matches (taking the per-domain catchall-separator and case-sensisitivity into account). for issue #18 --- .gitignore | 1 + config/config.go | 2 +- config/doc.go | 4 +- doc.go | 5 ++- http/account.html | 1 + http/admin.go | 4 +- http/admin.html | 15 ++++--- http/adminapi.json | 12 +++--- main.go | 8 +++- mox-/admin.go | 64 +++++++++++++++++------------ mox-/config.go | 45 +++++++++++++++----- mox-/lookup.go | 5 ++- smtpserver/server.go | 2 +- smtpserver/server_test.go | 45 ++++++++++++++++++++ testdata/smtp/catchall/domains.conf | 12 ++++++ testdata/smtp/catchall/mox.conf | 9 ++++ 16 files changed, 176 insertions(+), 58 deletions(-) create mode 100644 testdata/smtp/catchall/domains.conf create mode 100644 testdata/smtp/catchall/mox.conf diff --git a/.gitignore b/.gitignore index 77b66e5..3c52c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /testdata/smtp/data/ /testdata/smtp/datajunk/ /testdata/smtp/sendlimit/data/ +/testdata/smtp/catchall/data/ /testdata/store/data/ /testdata/train/ /cover.out diff --git a/config/config.go b/config/config.go index d5cd51b..dddc6bc 100644 --- a/config/config.go +++ b/config/config.go @@ -232,7 +232,7 @@ type DKIM struct { type Account struct { Domain string `sconf-doc:"Default domain for account. Deprecated behaviour: If a destination is not a full address but only a localpart, this domain is added to form a full address."` Description string `sconf:"optional" sconf-doc:"Free form description, e.g. full name or alternative contact info."` - Destinations map[string]Destination `sconf-doc:"Destinations, keys are email addresses (with IDNA domains). Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."` + Destinations map[string]Destination `sconf-doc:"Destinations, keys are email addresses (with IDNA domains). If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."` SubjectPass struct { Period time.Duration `sconf-doc:"How long unique values are accepted after generating, e.g. 12h."` // todo: have a reasonable default for this? } `sconf:"optional" sconf-doc:"If configured, messages classified as weakly spam are rejected with instructions to retry delivery, but this time with a signed token added to the subject. During the next delivery attempt, the signed token will bypass the spam filter. Messages with a clear spam signal, such as a known bad reputation, are rejected/delayed without a signed token."` diff --git a/config/doc.go b/config/doc.go index 7b2757d..ecda88b 100644 --- a/config/doc.go +++ b/config/doc.go @@ -468,7 +468,9 @@ describe-static" and "mox config describe-domains": # Free form description, e.g. full name or alternative contact info. (optional) Description: - # Destinations, keys are email addresses (with IDNA domains). Deprecated + # Destinations, keys are email addresses (with IDNA domains). If the address is of + # the form '@domain', i.e. with localpart missing, it serves as a catchall for the + # domain, matching all messages that are not explicitly configured. Deprecated # behaviour: If the address is not a full address but a localpart, it is combined # with Domain to form a full address. Destinations: diff --git a/doc.go b/doc.go index 901d30e..4f758e3 100644 --- a/doc.go +++ b/doc.go @@ -404,13 +404,16 @@ these addresses will be rejected. Adds an address to an account and reloads the configuration. +If address starts with a @ (i.e. a missing localpart), this is a catchall +address for the domain. + usage: mox config address add address account # mox config address rm Remove an address and reload the configuration. -Incoming email for this address will be rejected. +Incoming email for this address will be rejected after removing an address. usage: mox config address rm address diff --git a/http/account.html b/http/account.html index d42c8b4..e68891f 100644 --- a/http/account.html +++ b/http/account.html @@ -273,6 +273,7 @@ const index = async () => { Object.entries(destinations).sort().map(t => dom.li( dom.a(t[0], attr({href: '#destinations/'+t[0]})), + t[0].startsWith('@') ? ' (catchall)' : [], ), ), ), diff --git a/http/admin.go b/http/admin.go index faf09b0..f65d57f 100644 --- a/http/admin.go +++ b/http/admin.go @@ -1126,8 +1126,8 @@ func (Admin) Domain(ctx context.Context, domain string) dns.Domain { return d } -// DomainLocalparts returns the localparts and accounts configured in domain. -func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[smtp.Localpart]string) { +// DomainLocalparts returns the encoded localparts and accounts configured in domain. +func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string) { d, err := dns.ParseDomain(domain) xcheckf(ctx, err, "parsing domain") _, ok := mox.Conf.Domain(d) diff --git a/http/admin.html b/http/admin.html index 336e362..a133a6d 100644 --- a/http/admin.html +++ b/http/admin.html @@ -509,6 +509,9 @@ const account = async (name) => { lp, '@', dom.a(d, attr({href: '#domains/'+d})), ] + if (lp === '') { + v.unshift('(catchall) ') + } } return dom.tr( dom.td(v), @@ -568,9 +571,9 @@ const account = async (name) => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - 'Email address or localpart', + dom.span('Email address or localpart', attr({title: 'If empty, or localpart is empty, a catchall address is configured for the domain.'})), dom.br(), - email=dom.input(attr({required: ''})), + email=dom.input(), ), ' ', dom.button('Add address'), @@ -748,7 +751,7 @@ const domain = async (d) => { dom.tbody( Object.entries(localpartAccounts).map(t => dom.tr( - dom.td(t[0]), + dom.td(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr({href: '#accounts/'+t[1]}))), dom.td( dom.button('Remove address', async function click(e) { @@ -758,7 +761,7 @@ const domain = async (d) => { } e.target.disabled = true try { - await api.AddressRemove(t[0] + '@'+d) + await api.AddressRemove(t[0] + '@' + d) } catch (err) { console.log({err}) window.alert('Error: ' + err.message) @@ -795,9 +798,9 @@ const domain = async (d) => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - 'Localpart', + dom.span('Localpart', attr({title: 'An empty localpart is the catchall destination/address for the domain.'})), dom.br(), - localpart=dom.input(attr({required: ''})), + localpart=dom.input(), ), ' ', dom.label( diff --git a/http/adminapi.json b/http/adminapi.json index 09249bd..24b7f26 100644 --- a/http/adminapi.json +++ b/http/adminapi.json @@ -58,7 +58,7 @@ }, { "Name": "DomainLocalparts", - "Docs": "DomainLocalparts returns the localparts and accounts configured in domain.", + "Docs": "DomainLocalparts returns the encoded localparts and accounts configured in domain.", "Params": [ { "Name": "domain", @@ -3237,11 +3237,6 @@ } ] }, - { - "Name": "Localpart", - "Docs": "Localpart is a decoded local part of an email address, before the \"@\".\nFor quoted strings, values do not hold the double quote or escaping backslashes.\nAn empty string can be a valid localpart.", - "Values": null - }, { "Name": "ResultType", "Docs": "ResultType represents a TLS error.", @@ -3490,6 +3485,11 @@ } ] }, + { + "Name": "Localpart", + "Docs": "Localpart is a decoded local part of an email address, before the \"@\".\nFor quoted strings, values do not hold the double quote or escaping backslashes.\nAn empty string can be a valid localpart.", + "Values": null + }, { "Name": "IP", "Docs": "An IP is a single IP address, a slice of bytes.\nFunctions in this package accept either 4-byte (IPv4)\nor 16-byte (IPv6) slices as input.\n\nNote that in this documentation, referring to an\nIP address as an IPv4 address or an IPv6 address\nis a semantic property of the address, not just the\nlength of the byte slice: a 16-byte slice can still\nbe an IPv4 address.", diff --git a/main.go b/main.go index 62612d9..da90048 100644 --- a/main.go +++ b/main.go @@ -655,7 +655,11 @@ these addresses will be rejected. func cmdConfigAddressAdd(c *cmd) { c.params = "address account" - c.help = "Adds an address to an account and reloads the configuration." + c.help = `Adds an address to an account and reloads the configuration. + +If address starts with a @ (i.e. a missing localpart), this is a catchall +address for the domain. +` args := c.Parse() if len(args) != 2 { c.Usage() @@ -675,7 +679,7 @@ func cmdConfigAddressRemove(c *cmd) { c.params = "address" c.help = `Remove an address and reload the configuration. -Incoming email for this address will be rejected. +Incoming email for this address will be rejected after removing an address. ` args := c.Parse() if len(args) != 1 { diff --git a/mox-/admin.go b/mox-/admin.go index 7449c86..7df7f4a 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -555,6 +555,8 @@ func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) { // // The new account does not have a password, so cannot yet log in. Email can be // delivered. +// +// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd. func AccountAdd(ctx context.Context, account, address string) (rerr error) { log := xlog.WithContext(ctx) defer func() { @@ -648,8 +650,8 @@ func checkAddressAvailable(addr smtp.Address) error { return nil } -// AddressAdd adds an email address to an account and reloads the -// configuration. +// AddressAdd adds an email address to an account and reloads the configuration. If +// address starts with an @ it is treated as a catchall address for the domain. func AddressAdd(ctx context.Context, address, account string) (rerr error) { log := xlog.WithContext(ctx) defer func() { @@ -658,11 +660,6 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { } }() - addr, err := smtp.ParseAddress(address) - if err != nil { - return fmt.Errorf("parsing email address: %v", err) - } - Conf.dynamicMutex.Lock() defer Conf.dynamicMutex.Unlock() @@ -672,8 +669,29 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { return fmt.Errorf("account does not exist") } - if err := checkAddressAvailable(addr); err != nil { - return fmt.Errorf("address not available: %v", err) + var destAddr string + if strings.HasPrefix(address, "@") { + d, err := dns.ParseDomain(address[1:]) + if err != nil { + return fmt.Errorf("parsing domain: %v", err) + } + dname := d.Name() + destAddr = "@" + dname + if _, ok := Conf.Dynamic.Domains[dname]; !ok { + return fmt.Errorf("domain does not exist") + } else if _, ok := Conf.accountDestinations[destAddr]; ok { + return fmt.Errorf("catchall address already configured for domain") + } + } else { + addr, err := smtp.ParseAddress(address) + if err != nil { + return fmt.Errorf("parsing email address: %v", err) + } + + if err := checkAddressAvailable(addr); err != nil { + return fmt.Errorf("address not available: %v", err) + } + destAddr = addr.String() } // Compose new config without modifying existing data structures. If we fail, we @@ -687,14 +705,14 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) { for name, d := range a.Destinations { nd[name] = d } - nd[addr.String()] = config.Destination{} + nd[destAddr] = config.Destination{} a.Destinations = nd nc.Accounts[account] = a if err := writeDynamic(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %v", err) } - log.Info("address added", mlog.Field("address", addr), mlog.Field("account", account)) + log.Info("address added", mlog.Field("address", address), mlog.Field("account", account)) return nil } @@ -710,31 +728,23 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { Conf.dynamicMutex.Lock() defer Conf.dynamicMutex.Unlock() - c := Conf.Dynamic - - addr, err := smtp.ParseAddress(address) - if err != nil { - return fmt.Errorf("parsing email address: %v", err) - } - ad, ok := Conf.accountDestinations[addr.String()] + ad, ok := Conf.accountDestinations[address] if !ok { return fmt.Errorf("address does not exists") } - addrStr := addr.String() // Compose new config without modifying existing data structures. If we fail, we // leave no trace. - a, ok := c.Accounts[ad.Account] + a, ok := Conf.Dynamic.Accounts[ad.Account] if !ok { return fmt.Errorf("internal error: cannot find account") } na := a na.Destinations = map[string]config.Destination{} var dropped bool - for name, d := range a.Destinations { - // todo deprecated: remove support for localpart-only with default domain as destination address. - if !(name == addr.Localpart.String() && a.DNSDomain == addr.Domain || name == addrStr) { - na.Destinations[name] = d + for destAddr, d := range a.Destinations { + if destAddr != address { + na.Destinations[destAddr] = d } else { dropped = true } @@ -742,9 +752,9 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { if !dropped { return fmt.Errorf("address not removed, likely a postmaster/reporting address") } - nc := c + nc := Conf.Dynamic nc.Accounts = map[string]config.Account{} - for name, a := range c.Accounts { + for name, a := range Conf.Dynamic.Accounts { nc.Accounts[name] = a } nc.Accounts[ad.Account] = na @@ -752,7 +762,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { if err := writeDynamic(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %v", err) } - log.Info("address removed", mlog.Field("address", addr), mlog.Field("account", ad.Account)) + log.Info("address removed", mlog.Field("address", address), mlog.Field("account", ad.Account)) return nil } diff --git a/mox-/config.go b/mox-/config.go index f18d4e0..c1230ef 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -67,7 +67,8 @@ type Config struct { } type AccountDestination struct { - Localpart smtp.Localpart + Catchall bool // If catchall destination for its domain. + Localpart smtp.Localpart // In original casing as written in config file. Account string Destination config.Destination } @@ -167,13 +168,19 @@ func (c *Config) Accounts() (l []string) { return } -func (c *Config) DomainLocalparts(d dns.Domain) map[smtp.Localpart]string { +// DomainLocalparts returns a mapping of encoded localparts to account names for a +// domain. An empty localpart is a catchall destination for a domain. +func (c *Config) DomainLocalparts(d dns.Domain) map[string]string { suffix := "@" + d.Name() - m := map[smtp.Localpart]string{} + m := map[string]string{} c.withDynamicLock(func() { for addr, ad := range c.accountDestinations { if strings.HasSuffix(addr, suffix) { - m[ad.Localpart] = ad.Account + if ad.Catchall { + m[""] = ad.Account + } else { + m[ad.Localpart.String()] = ad.Account + } } } }) @@ -685,7 +692,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config errs = append(errs, fmt.Errorf(format, args...)) } - // check that mailbox is in unicode NFC normalized form. + // Check that mailbox is in unicode NFC normalized form. checkMailboxNormf := func(mailbox string, format string, args ...any) { s := norm.NFC.String(mailbox) if mailbox != s { @@ -930,10 +937,27 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config } } + // Catchall destination for domain. + if strings.HasPrefix(addrName, "@") { + d, err := dns.ParseDomain(addrName[1:]) + if err != nil { + addErrorf("parsing domain %q in account %q", addrName[1:], accName) + continue + } else if _, ok := c.Domains[d.Name()]; !ok { + addErrorf("unknown domain for address %q in account %q", addrName, accName) + continue + } + addrFull := "@" + d.Name() + if _, ok := accDests[addrFull]; ok { + addErrorf("duplicate canonicalized catchall destination address %s", addrFull) + } + accDests[addrFull] = AccountDestination{true, "", accName, dest} + continue + } + // todo deprecated: remove support for parsing destination as just a localpart instead full address. var address smtp.Address - localpart, err := smtp.ParseLocalpart(addrName) - if err != nil && errors.Is(err, smtp.ErrBadLocalpart) { + if localpart, err := smtp.ParseLocalpart(addrName); err != nil && errors.Is(err, smtp.ErrBadLocalpart) { address, err = smtp.ParseAddress(addrName) if err != nil { addErrorf("invalid email address %q in account %q", addrName, accName) @@ -955,6 +979,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config replaceLocalparts[addrName] = address.Pack(true) } + origLP := address.Localpart dc := c.Domains[address.Domain.Name()] if lp, err := CanonicalLocalpart(address.Localpart, dc); err != nil { addErrorf("canonicalizing localpart %s: %v", address.Localpart, err) @@ -967,7 +992,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config if _, ok := accDests[addrFull]; ok { addErrorf("duplicate canonicalized destination address %s", addrFull) } - accDests[addrFull] = AccountDestination{address.Localpart, accName, dest} + accDests[addrFull] = AccountDestination{false, origLP, accName, dest} } for lp, addr := range replaceLocalparts { @@ -1007,7 +1032,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config DMARCReports: true, } checkMailboxNormf(dmarc.Mailbox, "DMARC mailbox for account %q", dmarc.Account) - accDests[addrFull] = AccountDestination{lp, dmarc.Account, dest} + accDests[addrFull] = AccountDestination{false, lp, dmarc.Account, dest} } // Set TLSRPT destinations. @@ -1036,7 +1061,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config TLSReports: true, } checkMailboxNormf(tlsrpt.Mailbox, "TLSRPT mailbox for account %q", tlsrpt.Account) - accDests[addrFull] = AccountDestination{lp, tlsrpt.Account, dest} + accDests[addrFull] = AccountDestination{false, lp, tlsrpt.Account, dest} } // Check webserver configs. diff --git a/mox-/lookup.go b/mox-/lookup.go index f25c609..228d6f1 100644 --- a/mox-/lookup.go +++ b/mox-/lookup.go @@ -43,7 +43,10 @@ func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bo accAddr, ok := Conf.AccountDestination(canonical) if !ok { - return "", "", config.Destination{}, ErrAccountNotFound + if accAddr, ok = Conf.AccountDestination("@" + domain.Name()); !ok { + return "", "", config.Destination{}, ErrAccountNotFound + } + canonical = "@" + domain.Name() } return accAddr.Account, canonical, accAddr.Destination, nil } diff --git a/smtpserver/server.go b/smtpserver/server.go index f3dc82e..58f68c4 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -1625,7 +1625,7 @@ func messageHeaderCommentDomain(domain dns.Domain, smtputf8 bool) string { return s } -// submit is used for incoming mail from authenticated users. +// submit is used for mail from authenticated users that we will try to deliver. func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, pdataFile **os.File) { dataFile := *pdataFile diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 556a3da..4f4ab14 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -958,3 +958,48 @@ func TestLimitOutgoing(t *testing.T) { testSubmit("b@other.example", nil) testSubmit("b@other.example", &smtpclient.Error{Code: smtp.C451LocalErr, Secode: smtp.SePol7DeliveryUnauth1}) // Would be 5th message. } + +// Test with catchall destination address. +func TestCatchall(t *testing.T) { + resolver := dns.MockResolver{ + A: map[string][]string{ + "other.example.": {"127.0.0.10"}, // For mx check. + }, + PTR: map[string][]string{ + "127.0.0.10": {"other.example."}, + }, + } + ts := newTestServer(t, "../testdata/smtp/catchall/mox.conf", resolver) + defer ts.close() + + testDeliver := func(rcptTo string, expErr *smtpclient.Error) { + t.Helper() + ts.run(func(err error, client *smtpclient.Client) { + t.Helper() + mailFrom := "mjl@other.example" + if err == nil { + err = client.Deliver(context.Background(), mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false) + } + var cerr smtpclient.Error + if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) { + t.Fatalf("got err %#v, expected %#v", err, expErr) + } + }) + } + + 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](ts.acc.DB).Count() + tcheck(t, err, "checking delivered messages") + tcompare(t, n, 3) + + acc, err := store.OpenAccount("catchall") + tcheck(t, err, "open account") + defer acc.Close() + n, err = bstore.QueryDB[store.Message](acc.DB).Count() + tcheck(t, err, "checking delivered messages to catchall account") + tcompare(t, n, 1) +} diff --git a/testdata/smtp/catchall/domains.conf b/testdata/smtp/catchall/domains.conf new file mode 100644 index 0000000..681d841 --- /dev/null +++ b/testdata/smtp/catchall/domains.conf @@ -0,0 +1,12 @@ +Domains: + mox.example: + LocalpartCatchallSeparator: + +Accounts: + mjl: + Domain: mox.example + Destinations: + mjl@mox.example: nil + catchall: + Domain: mox.example + Destinations: + @mox.example: nil diff --git a/testdata/smtp/catchall/mox.conf b/testdata/smtp/catchall/mox.conf new file mode 100644 index 0000000..e1286db --- /dev/null +++ b/testdata/smtp/catchall/mox.conf @@ -0,0 +1,9 @@ +DataDir: data +User: 1000 +LogLevel: trace +Hostname: mox.example +Postmaster: + Account: mjl + Mailbox: postmaster +Listeners: + local: nil