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
This commit is contained in:
Mechiel Lukkien 2023-03-29 21:11:43 +02:00
parent 51ad345dbb
commit b571dd4b28
No known key found for this signature in database
16 changed files with 176 additions and 58 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
/testdata/smtp/data/ /testdata/smtp/data/
/testdata/smtp/datajunk/ /testdata/smtp/datajunk/
/testdata/smtp/sendlimit/data/ /testdata/smtp/sendlimit/data/
/testdata/smtp/catchall/data/
/testdata/store/data/ /testdata/store/data/
/testdata/train/ /testdata/train/
/cover.out /cover.out

View file

@ -232,7 +232,7 @@ type DKIM struct {
type Account 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."` 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."` 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 { 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? 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."` } `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."`

View file

@ -468,7 +468,9 @@ describe-static" and "mox config describe-domains":
# Free form description, e.g. full name or alternative contact info. (optional) # Free form description, e.g. full name or alternative contact info. (optional)
Description: 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 # behaviour: If the address is not a full address but a localpart, it is combined
# with Domain to form a full address. # with Domain to form a full address.
Destinations: Destinations:

5
doc.go
View file

@ -404,13 +404,16 @@ these addresses will be rejected.
Adds an address to an account and reloads the configuration. 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 usage: mox config address add address account
# mox config address rm # mox config address rm
Remove an address and reload the configuration. 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 usage: mox config address rm address

View file

@ -273,6 +273,7 @@ const index = async () => {
Object.entries(destinations).sort().map(t => Object.entries(destinations).sort().map(t =>
dom.li( dom.li(
dom.a(t[0], attr({href: '#destinations/'+t[0]})), dom.a(t[0], attr({href: '#destinations/'+t[0]})),
t[0].startsWith('@') ? ' (catchall)' : [],
), ),
), ),
), ),

View file

@ -1126,8 +1126,8 @@ func (Admin) Domain(ctx context.Context, domain string) dns.Domain {
return d return d
} }
// DomainLocalparts returns the localparts and accounts configured in domain. // DomainLocalparts returns the encoded localparts and accounts configured in domain.
func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[smtp.Localpart]string) { func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string) {
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
xcheckf(ctx, err, "parsing domain") xcheckf(ctx, err, "parsing domain")
_, ok := mox.Conf.Domain(d) _, ok := mox.Conf.Domain(d)

View file

@ -509,6 +509,9 @@ const account = async (name) => {
lp, '@', lp, '@',
dom.a(d, attr({href: '#domains/'+d})), dom.a(d, attr({href: '#domains/'+d})),
] ]
if (lp === '') {
v.unshift('(catchall) ')
}
} }
return dom.tr( return dom.tr(
dom.td(v), dom.td(v),
@ -568,9 +571,9 @@ const account = async (name) => {
fieldset=dom.fieldset( fieldset=dom.fieldset(
dom.label( dom.label(
style({display: 'inline-block'}), 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(), dom.br(),
email=dom.input(attr({required: ''})), email=dom.input(),
), ),
' ', ' ',
dom.button('Add address'), dom.button('Add address'),
@ -748,7 +751,7 @@ const domain = async (d) => {
dom.tbody( dom.tbody(
Object.entries(localpartAccounts).map(t => Object.entries(localpartAccounts).map(t =>
dom.tr( 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.a(t[1], attr({href: '#accounts/'+t[1]}))),
dom.td( dom.td(
dom.button('Remove address', async function click(e) { dom.button('Remove address', async function click(e) {
@ -758,7 +761,7 @@ const domain = async (d) => {
} }
e.target.disabled = true e.target.disabled = true
try { try {
await api.AddressRemove(t[0] + '@'+d) await api.AddressRemove(t[0] + '@' + d)
} catch (err) { } catch (err) {
console.log({err}) console.log({err})
window.alert('Error: ' + err.message) window.alert('Error: ' + err.message)
@ -795,9 +798,9 @@ const domain = async (d) => {
fieldset=dom.fieldset( fieldset=dom.fieldset(
dom.label( dom.label(
style({display: 'inline-block'}), style({display: 'inline-block'}),
'Localpart', dom.span('Localpart', attr({title: 'An empty localpart is the catchall destination/address for the domain.'})),
dom.br(), dom.br(),
localpart=dom.input(attr({required: ''})), localpart=dom.input(),
), ),
' ', ' ',
dom.label( dom.label(

View file

@ -58,7 +58,7 @@
}, },
{ {
"Name": "DomainLocalparts", "Name": "DomainLocalparts",
"Docs": "DomainLocalparts returns the localparts and accounts configured in domain.", "Docs": "DomainLocalparts returns the encoded localparts and accounts configured in domain.",
"Params": [ "Params": [
{ {
"Name": "domain", "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", "Name": "ResultType",
"Docs": "ResultType represents a TLS error.", "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", "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.", "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.",

View file

@ -655,7 +655,11 @@ these addresses will be rejected.
func cmdConfigAddressAdd(c *cmd) { func cmdConfigAddressAdd(c *cmd) {
c.params = "address account" 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() args := c.Parse()
if len(args) != 2 { if len(args) != 2 {
c.Usage() c.Usage()
@ -675,7 +679,7 @@ func cmdConfigAddressRemove(c *cmd) {
c.params = "address" c.params = "address"
c.help = `Remove an address and reload the configuration. 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() args := c.Parse()
if len(args) != 1 { if len(args) != 1 {

View file

@ -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 // The new account does not have a password, so cannot yet log in. Email can be
// delivered. // delivered.
//
// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd.
func AccountAdd(ctx context.Context, account, address string) (rerr error) { func AccountAdd(ctx context.Context, account, address string) (rerr error) {
log := xlog.WithContext(ctx) log := xlog.WithContext(ctx)
defer func() { defer func() {
@ -648,8 +650,8 @@ func checkAddressAvailable(addr smtp.Address) error {
return nil return nil
} }
// AddressAdd adds an email address to an account and reloads the // AddressAdd adds an email address to an account and reloads the configuration. If
// configuration. // address starts with an @ it is treated as a catchall address for the domain.
func AddressAdd(ctx context.Context, address, account string) (rerr error) { func AddressAdd(ctx context.Context, address, account string) (rerr error) {
log := xlog.WithContext(ctx) log := xlog.WithContext(ctx)
defer func() { 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() Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock() 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") return fmt.Errorf("account does not exist")
} }
if err := checkAddressAvailable(addr); err != nil { var destAddr string
return fmt.Errorf("address not available: %v", err) 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 // 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 { for name, d := range a.Destinations {
nd[name] = d nd[name] = d
} }
nd[addr.String()] = config.Destination{} nd[destAddr] = config.Destination{}
a.Destinations = nd a.Destinations = nd
nc.Accounts[account] = a nc.Accounts[account] = a
if err := writeDynamic(ctx, log, nc); err != nil { if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err) 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 return nil
} }
@ -710,31 +728,23 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
Conf.dynamicMutex.Lock() Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock() defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic ad, ok := Conf.accountDestinations[address]
addr, err := smtp.ParseAddress(address)
if err != nil {
return fmt.Errorf("parsing email address: %v", err)
}
ad, ok := Conf.accountDestinations[addr.String()]
if !ok { if !ok {
return fmt.Errorf("address does not exists") return fmt.Errorf("address does not exists")
} }
addrStr := addr.String()
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
// leave no trace. // leave no trace.
a, ok := c.Accounts[ad.Account] a, ok := Conf.Dynamic.Accounts[ad.Account]
if !ok { if !ok {
return fmt.Errorf("internal error: cannot find account") return fmt.Errorf("internal error: cannot find account")
} }
na := a na := a
na.Destinations = map[string]config.Destination{} na.Destinations = map[string]config.Destination{}
var dropped bool var dropped bool
for name, d := range a.Destinations { for destAddr, d := range a.Destinations {
// todo deprecated: remove support for localpart-only with default domain as destination address. if destAddr != address {
if !(name == addr.Localpart.String() && a.DNSDomain == addr.Domain || name == addrStr) { na.Destinations[destAddr] = d
na.Destinations[name] = d
} else { } else {
dropped = true dropped = true
} }
@ -742,9 +752,9 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if !dropped { if !dropped {
return fmt.Errorf("address not removed, likely a postmaster/reporting address") return fmt.Errorf("address not removed, likely a postmaster/reporting address")
} }
nc := c nc := Conf.Dynamic
nc.Accounts = map[string]config.Account{} 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[name] = a
} }
nc.Accounts[ad.Account] = na 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 { if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err) 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 return nil
} }

View file

@ -67,7 +67,8 @@ type Config struct {
} }
type AccountDestination 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 Account string
Destination config.Destination Destination config.Destination
} }
@ -167,13 +168,19 @@ func (c *Config) Accounts() (l []string) {
return 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() suffix := "@" + d.Name()
m := map[smtp.Localpart]string{} m := map[string]string{}
c.withDynamicLock(func() { c.withDynamicLock(func() {
for addr, ad := range c.accountDestinations { for addr, ad := range c.accountDestinations {
if strings.HasSuffix(addr, suffix) { 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...)) 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) { checkMailboxNormf := func(mailbox string, format string, args ...any) {
s := norm.NFC.String(mailbox) s := norm.NFC.String(mailbox)
if mailbox != s { 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. // todo deprecated: remove support for parsing destination as just a localpart instead full address.
var address smtp.Address var address smtp.Address
localpart, err := smtp.ParseLocalpart(addrName) if localpart, err := smtp.ParseLocalpart(addrName); err != nil && errors.Is(err, smtp.ErrBadLocalpart) {
if err != nil && errors.Is(err, smtp.ErrBadLocalpart) {
address, err = smtp.ParseAddress(addrName) address, err = smtp.ParseAddress(addrName)
if err != nil { if err != nil {
addErrorf("invalid email address %q in account %q", addrName, accName) 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) replaceLocalparts[addrName] = address.Pack(true)
} }
origLP := address.Localpart
dc := c.Domains[address.Domain.Name()] dc := c.Domains[address.Domain.Name()]
if lp, err := CanonicalLocalpart(address.Localpart, dc); err != nil { if lp, err := CanonicalLocalpart(address.Localpart, dc); err != nil {
addErrorf("canonicalizing localpart %s: %v", address.Localpart, err) 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 { if _, ok := accDests[addrFull]; ok {
addErrorf("duplicate canonicalized destination address %s", addrFull) 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 { for lp, addr := range replaceLocalparts {
@ -1007,7 +1032,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config
DMARCReports: true, DMARCReports: true,
} }
checkMailboxNormf(dmarc.Mailbox, "DMARC mailbox for account %q", dmarc.Account) 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. // Set TLSRPT destinations.
@ -1036,7 +1061,7 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config
TLSReports: true, TLSReports: true,
} }
checkMailboxNormf(tlsrpt.Mailbox, "TLSRPT mailbox for account %q", tlsrpt.Account) 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. // Check webserver configs.

View file

@ -43,7 +43,10 @@ func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bo
accAddr, ok := Conf.AccountDestination(canonical) accAddr, ok := Conf.AccountDestination(canonical)
if !ok { 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 return accAddr.Account, canonical, accAddr.Destination, nil
} }

View file

@ -1625,7 +1625,7 @@ func messageHeaderCommentDomain(domain dns.Domain, smtputf8 bool) string {
return s 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) { func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, pdataFile **os.File) {
dataFile := *pdataFile dataFile := *pdataFile

View file

@ -958,3 +958,48 @@ func TestLimitOutgoing(t *testing.T) {
testSubmit("b@other.example", nil) testSubmit("b@other.example", nil)
testSubmit("b@other.example", &smtpclient.Error{Code: smtp.C451LocalErr, Secode: smtp.SePol7DeliveryUnauth1}) // Would be 5th message. 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)
}

12
testdata/smtp/catchall/domains.conf vendored Normal file
View file

@ -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

9
testdata/smtp/catchall/mox.conf vendored Normal file
View file

@ -0,0 +1,9 @@
DataDir: data
User: 1000
LogLevel: trace
Hostname: mox.example
Postmaster:
Account: mjl
Mailbox: postmaster
Listeners:
local: nil