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
16 changed files with 176 additions and 58 deletions

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,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
}

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,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
}