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/datajunk/
/testdata/smtp/sendlimit/data/
/testdata/smtp/catchall/data/
/testdata/store/data/
/testdata/train/
/cover.out

View file

@ -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."`

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)
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:

5
doc.go
View file

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

View file

@ -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)' : [],
),
),
),

View file

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

View file

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

View file

@ -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.",

View file

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

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
// 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,9 +669,30 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
return fmt.Errorf("account does not exist")
}
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
// leave no trace.
@ -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
}

View file

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

View file

@ -43,8 +43,11 @@ func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bo
accAddr, ok := Conf.AccountDestination(canonical)
if !ok {
if accAddr, ok = Conf.AccountDestination("@" + domain.Name()); !ok {
return "", "", config.Destination{}, ErrAccountNotFound
}
canonical = "@" + domain.Name()
}
return accAddr.Account, canonical, accAddr.Destination, nil
}

View file

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

View file

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

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