add aliases/lists: when sending to an alias, the message gets delivered to all members

the members must currently all be addresses of local accounts.

a message sent to an alias is accepted if at least one of the members accepts
it. if no members accepts it (e.g. due to bad reputation of sender), the
message is rejected.

if a message is submitted to both an alias addresses and to recipients that are
members of the alias in an smtp transaction, the message will be delivered to
such members only once.  the same applies if the address in the message
from-header is the address of a member: that member won't receive the message
(they sent it). this prevents duplicate messages.

aliases have three configuration options:
- PostPublic: whether anyone can send through the alias, or only members.
  members-only lists can be useful inside organizations for internal
  communication. public lists can be useful for support addresses.
- ListMembers: whether members can see the addresses of other members. this can
  be seen in the account web interface. in the future, we could export this in
  other ways, so clients can expand the list.
- AllowMsgFrom: whether messages can be sent through the alias with the alias
  address used in the message from-header. the webmail knows it can use that
  address, and will use it as from-address when replying to a message sent to
  that address.

ideas for the future:
- allow external addresses as members. still with some restrictions, such as
  requiring a valid dkim-signature so delivery has a chance to succeed. will
  also need configuration of an admin that can receive any bounces.
- allow specifying specific members who can sent through the list (instead of
  all members).

for github issue #57 by hmfaysal.
also relevant for #99 by naturalethic.
thanks to damir & marin from sartura for discussing requirements/features.
This commit is contained in:
Mechiel Lukkien
2024-04-24 19:15:30 +02:00
parent 1cf7477642
commit 960a51242d
34 changed files with 2766 additions and 589 deletions

View File

@ -17,6 +17,7 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@ -605,7 +606,7 @@ func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths ma
// can modify the config, but must clone all referencing data it changes.
// xmodify may employ panic-based error handling. After xmodify returns, the
// modified config is verified, saved and takes effect.
func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain)) (rerr error) {
func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain) error) (rerr error) {
log := pkglog.WithContext(ctx)
defer func() {
if rerr != nil {
@ -622,7 +623,9 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
return fmt.Errorf("%w: domain not present", ErrRequest)
}
xmodify(&dom)
if err := xmodify(&dom); err != nil {
return err
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
@ -1031,14 +1034,17 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
//
// Must be called with config lock held.
func checkAddressAvailable(addr smtp.Address) error {
if dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]; !ok {
dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]
if !ok {
return fmt.Errorf("domain does not exist")
} else if lp, err := CanonicalLocalpart(addr.Localpart, dc); err != nil {
return fmt.Errorf("canonicalizing localpart: %v", err)
} else if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
}
lp := CanonicalLocalpart(addr.Localpart, dc)
if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
} else if _, ok := dc.Aliases[lp.String()]; ok {
return fmt.Errorf("address in use as alias")
}
return nil
}
@ -1177,14 +1183,8 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if !ok {
return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
}
flp, err := CanonicalLocalpart(fa.Localpart, dc)
if err != nil {
return fmt.Errorf("%w: getting canonical localpart for fromid login address %q: %v", ErrRequest, fa.Localpart, err)
}
alp, err := CanonicalLocalpart(pa.Localpart, dc)
if err != nil {
return fmt.Errorf("%w: getting canonical part for address: %v", ErrRequest, err)
}
flp := CanonicalLocalpart(fa.Localpart, dc)
alp := CanonicalLocalpart(pa.Localpart, dc)
if alp != flp {
// Keep for different localpart.
fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
@ -1206,6 +1206,88 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
return nil
}
func AliasAdd(ctx context.Context, addr smtp.Address, alias config.Alias) error {
return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
if _, ok := d.Aliases[addr.Localpart.String()]; ok {
return fmt.Errorf("%w: alias already present", ErrRequest)
}
if d.Aliases == nil {
d.Aliases = map[string]config.Alias{}
}
d.Aliases = maps.Clone(d.Aliases)
d.Aliases[addr.Localpart.String()] = alias
return nil
})
}
func AliasUpdate(ctx context.Context, addr smtp.Address, alias config.Alias) error {
return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
a, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("%w: alias does not exist", ErrRequest)
}
a.PostPublic = alias.PostPublic
a.ListMembers = alias.ListMembers
a.AllowMsgFrom = alias.AllowMsgFrom
d.Aliases = maps.Clone(d.Aliases)
d.Aliases[addr.Localpart.String()] = a
return nil
})
}
func AliasRemove(ctx context.Context, addr smtp.Address) error {
return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
_, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("%w: alias does not exist", ErrRequest)
}
d.Aliases = maps.Clone(d.Aliases)
delete(d.Aliases, addr.Localpart.String())
return nil
})
}
func AliasAddressesAdd(ctx context.Context, addr smtp.Address, addresses []string) error {
if len(addresses) == 0 {
return fmt.Errorf("%w: at least one address required", ErrRequest)
}
return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
alias, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("%w: no such alias", ErrRequest)
}
alias.Addresses = append(slices.Clone(alias.Addresses), addresses...)
alias.ParsedAddresses = nil
d.Aliases = maps.Clone(d.Aliases)
d.Aliases[addr.Localpart.String()] = alias
return nil
})
}
func AliasAddressesRemove(ctx context.Context, addr smtp.Address, addresses []string) error {
if len(addresses) == 0 {
return fmt.Errorf("%w: need at least one address", ErrRequest)
}
return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
alias, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("%w: no such alias", ErrRequest)
}
alias.Addresses = slices.DeleteFunc(slices.Clone(alias.Addresses), func(addr string) bool {
n := len(addresses)
addresses = slices.DeleteFunc(addresses, func(a string) bool { return a == addr })
return n > len(addresses)
})
if len(addresses) > 0 {
return fmt.Errorf("%w: address not found: %s", ErrRequest, strings.Join(addresses, ", "))
}
alias.ParsedAddresses = nil
d.Aliases = maps.Clone(d.Aliases)
d.Aliases[addr.Localpart.String()] = alias
return nil
})
}
// AccountSave updates the configuration of an account. Function xmodify is called
// with a shallow copy of the current configuration of the account. It must not
// change referencing fields (e.g. existing slice/map/pointer), they may still be

View File

@ -80,6 +80,8 @@ type Config struct {
// case-insensitive, stripped of catchall separator) to account and address.
// Domains are IDNA names in utf8.
accountDestinations map[string]AccountDestination
// Like accountDestinations, but for aliases.
aliases map[string]config.Alias
}
type AccountDestination struct {
@ -152,13 +154,14 @@ func (c *Config) withDynamicLock(fn func()) {
// must be called with dynamic lock held.
func (c *Config) loadDynamic() []error {
d, mtime, accDests, err := ParseDynamicConfig(context.Background(), pkglog, ConfigDynamicPath, c.Static)
d, mtime, accDests, aliases, err := ParseDynamicConfig(context.Background(), pkglog, ConfigDynamicPath, c.Static)
if err != nil {
return err
}
c.Dynamic = d
c.dynamicMtime = mtime
c.accountDestinations = accDests
c.aliases = aliases
c.allowACMEHosts(pkglog, true)
return nil
}
@ -193,10 +196,12 @@ func (c *Config) Accounts() (l []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 {
// domain, and encoded localparts to aliases. An empty localpart is a catchall
// destination for a domain.
func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]config.Alias) {
suffix := "@" + d.Name()
m := map[string]string{}
aliases := map[string]config.Alias{}
c.withDynamicLock(func() {
for addr, ad := range c.accountDestinations {
if strings.HasSuffix(addr, suffix) {
@ -207,8 +212,13 @@ func (c *Config) DomainLocalparts(d dns.Domain) map[string]string {
}
}
}
for addr, a := range c.aliases {
if strings.HasSuffix(addr, suffix) {
aliases[a.LocalpartStr] = a
}
}
})
return m
return m, aliases
}
func (c *Config) Domain(d dns.Domain) (dom config.Domain, ok bool) {
@ -225,9 +235,16 @@ func (c *Config) Account(name string) (acc config.Account, ok bool) {
return
}
func (c *Config) AccountDestination(addr string) (accDests AccountDestination, ok bool) {
func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) {
c.withDynamicLock(func() {
accDests, ok = c.accountDestinations[addr]
accDest, ok = c.accountDestinations[addr]
if !ok {
var a config.Alias
a, ok = c.aliases[addr]
if ok {
alias = &a
}
}
})
return
}
@ -314,7 +331,7 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
// must be called with lock held.
// Returns ErrConfig if the configuration is not valid.
func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
accDests, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c)
accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c)
if len(errs) > 0 {
return fmt.Errorf("%w: %v", ErrConfig, errs[0])
}
@ -362,6 +379,7 @@ func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
Conf.DynamicLastCheck = time.Now()
Conf.Dynamic = c
Conf.accountDestinations = accDests
Conf.aliases = aliases
Conf.allowACMEHosts(log, true)
@ -401,7 +419,7 @@ func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEH
// SetConfig sets a new config. Not to be used during normal operation.
func SetConfig(c *Config) {
// Cannot just assign *c to Conf, it would copy the mutex.
Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations}
Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations, c.aliases}
// If we have non-standard CA roots, use them for all HTTPS requests.
if Conf.Static.TLS.CertPool != nil {
@ -452,7 +470,7 @@ func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadT
}
pp := filepath.Join(filepath.Dir(p), "domains.conf")
c.Dynamic, c.dynamicMtime, c.accountDestinations, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
c.Dynamic, c.dynamicMtime, c.accountDestinations, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
if !checkOnly {
c.allowACMEHosts(log, checkACMEHosts)
@ -992,7 +1010,7 @@ func PrepareStaticConfig(ctx context.Context, log mlog.Log, configFile string, c
}
// PrepareDynamicConfig parses the dynamic config file given a static file.
func ParseDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, static config.Static) (c config.Dynamic, mtime time.Time, accDests map[string]AccountDestination, errs []error) {
func ParseDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, static config.Static) (c config.Dynamic, mtime time.Time, accDests map[string]AccountDestination, aliases map[string]config.Alias, errs []error) {
addErrorf := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...))
}
@ -1012,11 +1030,11 @@ func ParseDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, s
return
}
accDests, errs = prepareDynamicConfig(ctx, log, dynamicPath, static, &c)
return c, fi.ModTime(), accDests, errs
accDests, aliases, errs = prepareDynamicConfig(ctx, log, dynamicPath, static, &c)
return c, fi.ModTime(), accDests, aliases, errs
}
func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, static config.Static, c *config.Dynamic) (accDests map[string]AccountDestination, errs []error) {
func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, static config.Static, c *config.Dynamic) (accDests map[string]AccountDestination, aliases map[string]config.Alias, errs []error) {
addErrorf := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...))
}
@ -1037,6 +1055,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
checkMailboxNormf(static.Postmaster.Mailbox, "postmaster mailbox")
accDests = map[string]AccountDestination{}
aliases = map[string]config.Alias{}
// Validate host TLSRPT account/address.
if static.HostTLSRPT.Account != "" {
@ -1287,6 +1306,9 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
acc.ParsedFromIDLoginAddresses[i] = a
}
// Clear any previously derived state.
acc.Aliases = nil
c.Accounts[accName] = acc
if acc.OutgoingWebhook != nil {
@ -1445,9 +1467,8 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
origLP := address.Localpart
dc := c.Domains[address.Domain.Name()]
domainHasAddress[address.Domain.Name()] = true
if lp, err := CanonicalLocalpart(address.Localpart, dc); err != nil {
addErrorf("canonicalizing localpart %s: %v", address.Localpart, err)
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) {
lp := CanonicalLocalpart(address.Localpart, dc)
if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) {
addErrorf("localpart of address %s includes domain catchall separator %s", address, dc.LocalpartCatchallSeparator)
} else {
address.Localpart = lp
@ -1481,12 +1502,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
continue
}
dc := c.Domains[a.Domain.Name()]
lp, err := CanonicalLocalpart(a.Localpart, dc)
if err != nil {
addErrorf("canonicalizing localpart for fromid login address %q in account %q: %v", acc.FromIDLoginAddresses[i], accName, err)
continue
}
a.Localpart = lp
a.Localpart = CanonicalLocalpart(a.Localpart, dc)
if _, ok := accDests[a.Pack(true)]; !ok {
addErrorf("fromid login address %q for account %q does not match its destination addresses", acc.FromIDLoginAddresses[i], accName)
}
@ -1587,6 +1603,86 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
c.Domains[d] = domain
}
// Aliases, per domain. Also add references to accounts.
for d, domain := range c.Domains {
for lpstr, a := range domain.Aliases {
var err error
a.LocalpartStr = lpstr
var clp smtp.Localpart
lp, err := smtp.ParseLocalpart(lpstr)
if err != nil {
addErrorf("domain %q: parsing localpart %q for alias: %v", d, lpstr, err)
continue
} else if domain.LocalpartCatchallSeparator != "" && strings.Contains(string(lp), domain.LocalpartCatchallSeparator) {
addErrorf("domain %q: alias %q contains localpart catchall separator", d, a.LocalpartStr)
continue
} else {
clp = CanonicalLocalpart(lp, domain)
}
addr := smtp.NewAddress(clp, domain.Domain).Pack(true)
if _, ok := aliases[addr]; ok {
addErrorf("domain %q: duplicate alias address %q", d, addr)
continue
}
if _, ok := accDests[addr]; ok {
addErrorf("domain %q: alias %q already present as regular address", d, addr)
continue
}
if len(a.Addresses) == 0 {
// Not currently possible, Addresses isn't optional.
addErrorf("domain %q: alias %q needs at least one destination address", d, addr)
continue
}
a.ParsedAddresses = make([]config.AliasAddress, 0, len(a.Addresses))
seen := map[string]bool{}
for _, destAddr := range a.Addresses {
da, err := smtp.ParseAddress(destAddr)
if err != nil {
addErrorf("domain %q: parsing destination address %q in alias %q: %v", d, destAddr, addr, err)
continue
}
dastr := da.Pack(true)
accDest, ok := accDests[dastr]
if !ok {
addErrorf("domain %q: alias %q references non-existent address %q", d, addr, destAddr)
continue
}
if seen[dastr] {
addErrorf("domain %q: alias %q has duplicate address %q", d, addr, destAddr)
continue
}
seen[dastr] = true
aa := config.AliasAddress{Address: da, AccountName: accDest.Account, Destination: accDest.Destination}
a.ParsedAddresses = append(a.ParsedAddresses, aa)
}
a.Domain = domain.Domain
c.Domains[d].Aliases[lpstr] = a
aliases[addr] = a
for _, aa := range a.ParsedAddresses {
acc := c.Accounts[aa.AccountName]
var addrs []string
if a.ListMembers {
addrs = make([]string, len(a.ParsedAddresses))
for i := range a.ParsedAddresses {
addrs[i] = a.ParsedAddresses[i].Address.Pack(true)
}
}
// Keep the non-sensitive fields.
accAlias := config.Alias{
PostPublic: a.PostPublic,
ListMembers: a.ListMembers,
AllowMsgFrom: a.AllowMsgFrom,
LocalpartStr: a.LocalpartStr,
Domain: a.Domain,
}
acc.Aliases = append(acc.Aliases, config.AddressAlias{SubscriptionAddress: aa.Address.Pack(true), Alias: accAlias, MemberAddresses: addrs})
c.Accounts[aa.AccountName] = acc
}
}
}
// Check webserver configs.
if (len(c.WebDomainRedirects) > 0 || len(c.WebHandlers) > 0) && !haveWebserverListener {
addErrorf("WebDomainRedirects or WebHandlers configured but no listener with WebserverHTTP or WebserverHTTPS enabled")

View File

@ -2,7 +2,6 @@ package mox
import (
"errors"
"fmt"
"strings"
"github.com/mjl-/mox/config"
@ -12,13 +11,13 @@ import (
var (
ErrDomainNotFound = errors.New("domain not found")
ErrAccountNotFound = errors.New("account not found")
ErrAddressNotFound = errors.New("address not found")
)
// FindAccount looks up the account for localpart and domain.
//
// Can return ErrDomainNotFound and ErrAccountNotFound.
func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bool) (accountName string, canonicalAddress string, dest config.Destination, rerr error) {
// Can return ErrDomainNotFound and ErrAddressNotFound.
func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, allowAlias bool) (accountName string, alias *config.Alias, canonicalAddress string, dest config.Destination, rerr error) {
if strings.EqualFold(string(localpart), "postmaster") {
localpart = "postmaster"
}
@ -39,49 +38,48 @@ func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bo
// Check for special mail host addresses.
if localpart == "postmaster" && postmasterDomain() {
if !allowPostmaster {
return "", "", config.Destination{}, ErrAccountNotFound
return "", nil, "", config.Destination{}, ErrAddressNotFound
}
return Conf.Static.Postmaster.Account, "postmaster", config.Destination{Mailbox: Conf.Static.Postmaster.Mailbox}, nil
return Conf.Static.Postmaster.Account, nil, "postmaster", config.Destination{Mailbox: Conf.Static.Postmaster.Mailbox}, nil
}
if localpart == Conf.Static.HostTLSRPT.ParsedLocalpart && domain == Conf.Static.HostnameDomain {
// Get destination, should always be present.
canonical := smtp.NewAddress(localpart, domain).String()
accAddr, ok := Conf.AccountDestination(canonical)
if !ok {
return "", "", config.Destination{}, ErrAccountNotFound
accAddr, a, ok := Conf.AccountDestination(canonical)
if !ok || a != nil {
return "", nil, "", config.Destination{}, ErrAddressNotFound
}
return accAddr.Account, canonical, accAddr.Destination, nil
return accAddr.Account, nil, canonical, accAddr.Destination, nil
}
d, ok := Conf.Domain(domain)
if !ok || d.ReportsOnly {
// For ReportsOnly, we also return ErrDomainNotFound, so this domain isn't
// considered local/authoritative during delivery.
return "", "", config.Destination{}, ErrDomainNotFound
return "", nil, "", config.Destination{}, ErrDomainNotFound
}
localpart, err := CanonicalLocalpart(localpart, d)
if err != nil {
return "", "", config.Destination{}, fmt.Errorf("%w: %s", ErrAccountNotFound, err)
}
localpart = CanonicalLocalpart(localpart, d)
canonical := smtp.NewAddress(localpart, domain).String()
accAddr, ok := Conf.AccountDestination(canonical)
if !ok {
if accAddr, ok = Conf.AccountDestination("@" + domain.Name()); !ok {
accAddr, alias, ok := Conf.AccountDestination(canonical)
if ok && alias != nil && allowAlias {
return "", alias, canonical, config.Destination{}, nil
} else if !ok {
if accAddr, alias, ok = Conf.AccountDestination("@" + domain.Name()); !ok || alias != nil {
if localpart == "postmaster" && allowPostmaster {
return Conf.Static.Postmaster.Account, "postmaster", config.Destination{Mailbox: Conf.Static.Postmaster.Mailbox}, nil
return Conf.Static.Postmaster.Account, nil, "postmaster", config.Destination{Mailbox: Conf.Static.Postmaster.Mailbox}, nil
}
return "", "", config.Destination{}, ErrAccountNotFound
return "", nil, "", config.Destination{}, ErrAddressNotFound
}
canonical = "@" + domain.Name()
}
return accAddr.Account, canonical, accAddr.Destination, nil
return accAddr.Account, nil, canonical, accAddr.Destination, nil
}
// CanonicalLocalpart returns the canonical localpart, removing optional catchall
// separator, and optionally lower-casing the string.
func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) (smtp.Localpart, error) {
func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) smtp.Localpart {
if d.LocalpartCatchallSeparator != "" {
t := strings.SplitN(string(localpart), d.LocalpartCatchallSeparator, 2)
localpart = smtp.Localpart(t[0])
@ -90,5 +88,24 @@ func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) (smtp.Localpa
if !d.LocalpartCaseSensitive {
localpart = smtp.Localpart(strings.ToLower(string(localpart)))
}
return localpart, nil
return localpart
}
// AllowMsgFrom returns whether account is allowed to submit messages with address
// as message From header, based on configured addresses and membership of aliases
// that allow using its address.
func AllowMsgFrom(accountName string, msgFrom smtp.Address) bool {
accName, alias, _, _, err := LookupAddress(msgFrom.Localpart, msgFrom.Domain, false, true)
if err != nil {
return false
}
if alias != nil && alias.AllowMsgFrom {
for _, aa := range alias.ParsedAddresses {
if aa.AccountName == accountName {
return true
}
}
return false
}
return accName == accountName
}