mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 00:13:47 +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/datajunk/
|
||||
/testdata/smtp/sendlimit/data/
|
||||
/testdata/smtp/catchall/data/
|
||||
/testdata/store/data/
|
||||
/testdata/train/
|
||||
/cover.out
|
||||
|
|
|
@ -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."`
|
||||
|
|
|
@ -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
5
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
|
||||
|
||||
|
|
|
@ -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)' : [],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.",
|
||||
|
|
8
main.go
8
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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
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