mirror of
https://github.com/mjl-/mox.git
synced 2024-12-27 08:53:48 +03:00
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:
parent
51ad345dbb
commit
b571dd4b28
16 changed files with 176 additions and 58 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||||
|
|
|
@ -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."`
|
||||||
|
|
|
@ -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
5
doc.go
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)' : [],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
8
main.go
8
main.go
|
@ -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 {
|
||||||
|
|
|
@ -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,9 +669,30 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
|
||||||
return fmt.Errorf("account does not exist")
|
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 {
|
if err := checkAddressAvailable(addr); err != nil {
|
||||||
return fmt.Errorf("address not available: %v", err)
|
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
|
||||||
// leave no trace.
|
// leave no trace.
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -43,8 +43,11 @@ func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bo
|
||||||
|
|
||||||
accAddr, ok := Conf.AccountDestination(canonical)
|
accAddr, ok := Conf.AccountDestination(canonical)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if accAddr, ok = Conf.AccountDestination("@" + domain.Name()); !ok {
|
||||||
return "", "", config.Destination{}, ErrAccountNotFound
|
return "", "", config.Destination{}, ErrAccountNotFound
|
||||||
}
|
}
|
||||||
|
canonical = "@" + domain.Name()
|
||||||
|
}
|
||||||
return accAddr.Account, canonical, accAddr.Destination, nil
|
return accAddr.Account, canonical, accAddr.Destination, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
12
testdata/smtp/catchall/domains.conf
vendored
Normal 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
9
testdata/smtp/catchall/mox.conf
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
DataDir: data
|
||||||
|
User: 1000
|
||||||
|
LogLevel: trace
|
||||||
|
Hostname: mox.example
|
||||||
|
Postmaster:
|
||||||
|
Account: mjl
|
||||||
|
Mailbox: postmaster
|
||||||
|
Listeners:
|
||||||
|
local: nil
|
Loading…
Reference in a new issue