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
No known key found for this signature in database
34 changed files with 2766 additions and 589 deletions

View file

@ -24,10 +24,10 @@ See Quickstart below to get started.
- Automatic TLS with ACME, for use with Let's Encrypt and other CA's.
- DANE and MTA-STS for inbound and outbound delivery over SMTP with STARTTLS,
including REQUIRETLS and with incoming/outgoing TLSRPT reporting.
- Web admin interface that helps you set up your domains and accounts
(instructions to create DNS records, configure
SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing
accounts/domains, and modifying the configuration file.
- Web admin interface that helps you set up your domains, accounts and list
aliases (instructions to create DNS records, configure
SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, and modifying the
configuration file.
- Account autodiscovery (with SRV records, Microsoft-style, Thunderbird-style,
and Apple device management profiles) for easy account setup (though client
support is limited).
@ -135,7 +135,6 @@ https://nlnet.nl/project/Mox/.
## Roadmap
- Aliases, for delivering to multiple local accounts.
- Calendaring with CalDAV/iCal
- More IMAP extensions (PREVIEW, WITHIN, IMPORTANT, COMPRESS=DEFLATE,
CREATE-SPECIAL-USE, SAVEDATE, UNAUTHENTICATE, REPLACE, QUOTA, NOTIFY,
@ -145,6 +144,7 @@ https://nlnet.nl/project/Mox/.
- Forwarding (to an external address)
- Add special IMAP mailbox ("Queue?") that contains queued but
undelivered messages, updated with IMAP flags/keywords/tags and message headers.
- External addresses in aliases/lists.
- Sieve for filtering (for now see Rulesets in the account config)
- Autoresponder (out of office/vacation)
- OAUTH2 support, for single sign on

View file

@ -282,8 +282,9 @@ type Domain struct {
MTASTS *MTASTS `sconf:"optional" sconf-doc:"MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy."`
TLSRPT *TLSRPT `sconf:"optional" sconf-doc:"With TLSRPT a domain specifies in DNS where reports about encountered SMTP TLS behaviour should be sent. Useful for monitoring. Incoming TLS reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, these domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`
Aliases map[string]Alias `sconf:"optional" sconf-doc:"Aliases that cause messages to be delivered to one or more locally configured addresses. Keys are localparts (encoded, as they appear in email addresses)."`
Domain dns.Domain `sconf:"-" json:"-"`
Domain dns.Domain `sconf:"-"`
ClientSettingsDNSDomain dns.Domain `sconf:"-" json:"-"`
// Set when DMARC and TLSRPT (when set) has an address with different domain (we're
@ -292,6 +293,27 @@ type Domain struct {
ReportsOnly bool `sconf:"-" json:"-"`
}
// todo: allow external addresses as members of aliases. we would add messages for them to the queue for outgoing delivery. we should require an admin addresses to which delivery failures will be delivered (locally, and to use in smtp mail from, so dsns go there). also take care to evaluate smtputf8 (if external address requires utf8 and incoming transaction didn't).
// todo: as alternative to PostPublic, allow specifying a list of addresses (dmarc-like verified) that are (the only addresses) allowed to post to the list. if msgfrom is an external address, require a valid dkim signature to prevent dmarc-policy-related issues when delivering to remote members.
// todo: add option to require messages sent to an alias have that alias as From or Reply-To address?
type Alias struct {
Addresses []string `sconf-doc:"Expanded addresses to deliver to. These must currently be of addresses of local accounts. To prevent duplicate messages, a member address that is also an explicit recipient in the SMTP transaction will only have the message delivered once. If the address in the message From header is a member, that member also won't receive the message."`
PostPublic bool `sconf:"optional" sconf-doc:"If true, anyone can send messages to the list. Otherwise only members, based on message From address, which is assumed to be DMARC-like-verified."`
ListMembers bool `sconf:"optional" sconf-doc:"If true, members can see addresses of members."`
AllowMsgFrom bool `sconf:"optional" sconf-doc:"If true, members are allowed to send messages with this alias address in the message From header."`
LocalpartStr string `sconf:"-"` // In encoded form.
Domain dns.Domain `sconf:"-"`
ParsedAddresses []AliasAddress `sconf:"-"` // Matches addresses.
}
type AliasAddress struct {
Address smtp.Address // Parsed address.
AccountName string // Looked up.
Destination Destination // Belonging to address.
}
type DMARC struct {
Localpart string `sconf-doc:"Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for reporting address, for incoming reports. Typically empty, causing the domain wherein this config exists to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the DMARC DNS record for the not fully hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the suggested DNS settings. Unicode name."`
@ -412,6 +434,13 @@ type Account struct {
NeutralMailbox *regexp.Regexp `sconf:"-" json:"-"`
NotJunkMailbox *regexp.Regexp `sconf:"-" json:"-"`
ParsedFromIDLoginAddresses []smtp.Address `sconf:"-" json:"-"`
Aliases []AddressAlias `sconf:"-"`
}
type AddressAlias struct {
SubscriptionAddress string
Alias Alias // Without members.
MemberAddresses []string // Only if allowed to see.
}
type JunkFilter struct {

View file

@ -913,6 +913,31 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
MinimumAttempts: 0
Transport:
# Aliases that cause messages to be delivered to one or more locally configured
# addresses. Keys are localparts (encoded, as they appear in email addresses).
# (optional)
Aliases:
x:
# Expanded addresses to deliver to. These must currently be of addresses of local
# accounts. To prevent duplicate messages, a member address that is also an
# explicit recipient in the SMTP transaction will only have the message delivered
# once. If the address in the message From header is a member, that member also
# won't receive the message.
Addresses:
-
# If true, anyone can send messages to the list. Otherwise only members, based on
# message From address, which is assumed to be DMARC-like-verified. (optional)
PostPublic: false
# If true, members can see addresses of members. (optional)
ListMembers: false
# If true, members are allowed to send messages with this alias address in the
# message From header. (optional)
AllowMsgFrom: false
# Accounts represent mox users, each with a password and email address(es) to
# which email can be delivered (possibly at different domains). Each account has
# its own on-disk directory holding its messages and index database. An account

161
ctl.go
View file

@ -9,6 +9,7 @@ import (
"io"
"log"
"log/slog"
"maps"
"net"
"os"
"path/filepath"
@ -20,6 +21,7 @@ import (
"github.com/mjl-/bstore"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/metrics"
@ -1017,6 +1019,165 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "removing address")
ctl.xwriteok()
case "aliaslist":
/* protocol:
> "aliaslist"
> domain
< "ok" or error
< stream
*/
domain := ctl.xread()
d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain")
dc, ok := mox.Conf.Domain(d)
if !ok {
ctl.xcheck(errors.New("no such domain"), "listing aliases")
}
ctl.xwriteok()
w := ctl.writer()
for _, a := range dc.Aliases {
lp, err := smtp.ParseLocalpart(a.LocalpartStr)
ctl.xcheck(err, "parsing alias localpart")
fmt.Fprintln(w, smtp.NewAddress(lp, a.Domain).Pack(true))
}
w.xclose()
case "aliasprint":
/* protocol:
> "aliasprint"
> address
< "ok" or error
< stream
*/
address := ctl.xread()
_, alias, ok := mox.Conf.AccountDestination(address)
if !ok {
ctl.xcheck(errors.New("no such address"), "looking up alias")
} else if alias == nil {
ctl.xcheck(errors.New("address not an alias"), "looking up alias")
}
ctl.xwriteok()
w := ctl.writer()
fmt.Fprintf(w, "# postpublic %v\n", alias.PostPublic)
fmt.Fprintf(w, "# listmembers %v\n", alias.ListMembers)
fmt.Fprintf(w, "# allowmsgfrom %v\n", alias.AllowMsgFrom)
fmt.Fprintln(w, "# members:")
for _, a := range alias.Addresses {
fmt.Fprintln(w, a)
}
w.xclose()
case "aliasadd":
/* protocol:
> "aliasadd"
> address
> json alias
< "ok" or error
*/
address := ctl.xread()
line := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
var alias config.Alias
xparseJSON(ctl, line, &alias)
err = mox.AliasAdd(ctx, addr, alias)
ctl.xcheck(err, "adding alias")
ctl.xwriteok()
case "aliasupdate":
/* protocol:
> "aliasupdate"
> alias
> "true" or "false" for postpublic
> "true" or "false" for listmembers
> "true" or "false" for allowmsgfrom
< "ok" or error
*/
address := ctl.xread()
postpublic := ctl.xread()
listmembers := ctl.xread()
allowmsgfrom := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
err = mox.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
a, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("alias does not exist")
}
switch postpublic {
case "false":
a.PostPublic = false
case "true":
a.PostPublic = true
}
switch listmembers {
case "false":
a.ListMembers = false
case "true":
a.ListMembers = true
}
switch allowmsgfrom {
case "false":
a.AllowMsgFrom = false
case "true":
a.AllowMsgFrom = true
}
d.Aliases = maps.Clone(d.Aliases)
d.Aliases[addr.Localpart.String()] = a
return nil
})
ctl.xcheck(err, "saving alias")
ctl.xwriteok()
case "aliasrm":
/* protocol:
> "aliasrm"
> alias
< "ok" or error
*/
address := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
err = mox.AliasRemove(ctx, addr)
ctl.xcheck(err, "removing alias")
ctl.xwriteok()
case "aliasaddaddr":
/* protocol:
> "aliasaddaddr"
> alias
> addresses as json
< "ok" or error
*/
address := ctl.xread()
line := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesAdd(ctx, addr, addresses)
ctl.xcheck(err, "adding addresses to alias")
ctl.xwriteok()
case "aliasrmaddr":
/* protocol:
> "aliasrmaddr"
> alias
> addresses as json
< "ok" or error
*/
address := ctl.xread()
line := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesRemove(ctx, addr, addresses)
ctl.xcheck(err, "removing addresses to alias")
ctl.xwriteok()
case "loglevels":
/* protocol:
> "loglevels"

View file

@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dmarcdb"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
@ -292,6 +293,41 @@ func TestCtl(t *testing.T) {
ctlcmdConfigDomainRemove(ctl, dns.Domain{ASCII: "mox2.example"})
})
// "aliasadd"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasAdd(ctl, "support@mox.example", config.Alias{Addresses: []string{"mjl@mox.example"}})
})
// "aliaslist"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasList(ctl, "mox.example")
})
// "aliasprint"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasPrint(ctl, "support@mox.example")
})
// "aliasupdate"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasUpdate(ctl, "support@mox.example", "true", "true", "true")
})
// "aliasaddaddr"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasAddaddr(ctl, "support@mox.example", []string{"mjl2@mox.example"})
})
// "aliasrmaddr"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasRmaddr(ctl, "support@mox.example", []string{"mjl2@mox.example"})
})
// "aliasrm"
testctl(func(ctl *ctl) {
ctlcmdConfigAliasRemove(ctl, "support@mox.example")
})
// "loglevels"
testctl(func(ctl *ctl) {
ctlcmdLoglevels(ctl)

55
doc.go
View file

@ -68,6 +68,13 @@ any parameters. Followed by the help and usage information for each command.
mox config address rm address
mox config domain add domain account [localpart]
mox config domain rm domain
mox config alias list domain
mox config alias print alias
mox config alias add alias@domain rcpt1@domain ...
mox config alias update alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true]
mox config alias rm alias@domain
mox config alias addaddr alias@domain rcpt1@domain ...
mox config alias rmaddr alias@domain rcpt1@domain ...
mox config describe-sendmail >/etc/moxsubmit.conf
mox config printservice >mox.service
mox config ensureacmehostprivatekeys
@ -968,6 +975,54 @@ rejected.
usage: mox config domain rm domain
# mox config alias list
List aliases for domain.
usage: mox config alias list domain
# mox config alias print
Print settings and members of alias.
usage: mox config alias print alias
# mox config alias add
Add new alias with one or more addresses.
usage: mox config alias add alias@domain rcpt1@domain ...
# mox config alias update
Update alias configuration.
usage: mox config alias update alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true]
-allowmsgfrom string
whether alias address can be used in message from header
-listmembers string
whether list members can list members
-postpublic string
whether anyone or only list members can post
# mox config alias rm
Remove alias.
usage: mox config alias rm alias@domain
# mox config alias addaddr
Add addresses to alias.
usage: mox config alias addaddr alias@domain rcpt1@domain ...
# mox config alias rmaddr
Remove addresses from alias.
usage: mox config alias rmaddr alias@domain rcpt1@domain ...
# mox config describe-sendmail
Describe configuration for mox when invoked as sendmail.

149
main.go
View file

@ -148,6 +148,14 @@ var commands = []struct {
{"config address rm", cmdConfigAddressRemove},
{"config domain add", cmdConfigDomainAdd},
{"config domain rm", cmdConfigDomainRemove},
{"config alias list", cmdConfigAliasList},
{"config alias print", cmdConfigAliasPrint},
{"config alias add", cmdConfigAliasAdd},
{"config alias update", cmdConfigAliasUpdate},
{"config alias rm", cmdConfigAliasRemove},
{"config alias addaddr", cmdConfigAliasAddaddr},
{"config alias rmaddr", cmdConfigAliasRemoveaddr},
{"config describe-sendmail", cmdConfigDescribeSendmail},
{"config printservice", cmdConfigPrintservice},
{"config ensureacmehostprivatekeys", cmdConfigEnsureACMEHostprivatekeys},
@ -711,6 +719,147 @@ func ctlcmdConfigDomainRemove(ctl *ctl, d dns.Domain) {
fmt.Printf("domain removed, remember to remove dns records for %s\n", d)
}
func cmdConfigAliasList(c *cmd) {
c.params = "domain"
c.help = `List aliases for domain.`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdConfigAliasList(xctl(), args[0])
}
func ctlcmdConfigAliasList(ctl *ctl, address string) {
ctl.xwrite("aliaslist")
ctl.xwrite(address)
ctl.xreadok()
ctl.xstreamto(os.Stdout)
}
func cmdConfigAliasPrint(c *cmd) {
c.params = "alias"
c.help = `Print settings and members of alias.`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdConfigAliasPrint(xctl(), args[0])
}
func ctlcmdConfigAliasPrint(ctl *ctl, address string) {
ctl.xwrite("aliasprint")
ctl.xwrite(address)
ctl.xreadok()
ctl.xstreamto(os.Stdout)
}
func cmdConfigAliasAdd(c *cmd) {
c.params = "alias@domain rcpt1@domain ..."
c.help = `Add new alias with one or more addresses.`
args := c.Parse()
if len(args) < 2 {
c.Usage()
}
alias := config.Alias{Addresses: args[1:]}
mustLoadConfig()
ctlcmdConfigAliasAdd(xctl(), args[0], alias)
}
func ctlcmdConfigAliasAdd(ctl *ctl, address string, alias config.Alias) {
ctl.xwrite("aliasadd")
ctl.xwrite(address)
xctlwriteJSON(ctl, alias)
ctl.xreadok()
}
func cmdConfigAliasUpdate(c *cmd) {
c.params = "alias@domain [-postpublic false|true -listmembers false|true -allowmsgfrom false|true]"
c.help = `Update alias configuration.`
var postpublic, listmembers, allowmsgfrom string
c.flag.StringVar(&postpublic, "postpublic", "", "whether anyone or only list members can post")
c.flag.StringVar(&listmembers, "listmembers", "", "whether list members can list members")
c.flag.StringVar(&allowmsgfrom, "allowmsgfrom", "", "whether alias address can be used in message from header")
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
alias := args[0]
mustLoadConfig()
ctlcmdConfigAliasUpdate(xctl(), alias, postpublic, listmembers, allowmsgfrom)
}
func ctlcmdConfigAliasUpdate(ctl *ctl, alias, postpublic, listmembers, allowmsgfrom string) {
ctl.xwrite("aliasupdate")
ctl.xwrite(alias)
ctl.xwrite(postpublic)
ctl.xwrite(listmembers)
ctl.xwrite(allowmsgfrom)
ctl.xreadok()
}
func cmdConfigAliasRemove(c *cmd) {
c.params = "alias@domain"
c.help = "Remove alias."
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
mustLoadConfig()
ctlcmdConfigAliasRemove(xctl(), args[0])
}
func ctlcmdConfigAliasRemove(ctl *ctl, alias string) {
ctl.xwrite("aliasrm")
ctl.xwrite(alias)
ctl.xreadok()
}
func cmdConfigAliasAddaddr(c *cmd) {
c.params = "alias@domain rcpt1@domain ..."
c.help = `Add addresses to alias.`
args := c.Parse()
if len(args) < 2 {
c.Usage()
}
mustLoadConfig()
ctlcmdConfigAliasAddaddr(xctl(), args[0], args[1:])
}
func ctlcmdConfigAliasAddaddr(ctl *ctl, alias string, addresses []string) {
ctl.xwrite("aliasaddaddr")
ctl.xwrite(alias)
xctlwriteJSON(ctl, addresses)
ctl.xreadok()
}
func cmdConfigAliasRemoveaddr(c *cmd) {
c.params = "alias@domain rcpt1@domain ..."
c.help = `Remove addresses from alias.`
args := c.Parse()
if len(args) < 2 {
c.Usage()
}
mustLoadConfig()
ctlcmdConfigAliasRmaddr(xctl(), args[0], args[1:])
}
func ctlcmdConfigAliasRmaddr(ctl *ctl, alias string, addresses []string) {
ctl.xwrite("aliasrmaddr")
ctl.xwrite(alias)
xctlwriteJSON(ctl, addresses)
ctl.xreadok()
}
func cmdConfigAccountAdd(c *cmd) {
c.params = "account address"
c.help = `Add an account with an email address and reload the configuration.

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
}

292
smtpserver/alias_test.go Normal file
View file

@ -0,0 +1,292 @@
package smtpserver
import (
"path/filepath"
"strings"
"testing"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient"
"github.com/mjl-/mox/store"
)
// Check user can submit message with message From address they are member of.
func TestAliasSubmitMsgFrom(t *testing.T) {
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{})
defer ts.close()
ts.submission = true
ts.user = "mjl@mox.example"
ts.pass = password0
var msg = strings.ReplaceAll(`From: <public@mox.example>
To: <public@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "public@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, nil)
})
msg = strings.ReplaceAll(`From: <private@mox.example>
To: <private@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7DeliveryUnauth1})
})
}
// Non-member cannot submit as alias that allows it for members.
func TestAliasSubmitMsgFromDenied(t *testing.T) {
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{})
defer ts.close()
acc, err := store.OpenAccount(pkglog, "☺")
tcheck(t, err, "open account")
err = acc.SetPassword(pkglog, password0)
tcheck(t, err, "set password")
err = acc.Close()
tcheck(t, err, "close account")
acc.CheckClosed()
ts.submission = true
ts.user = "☺@mox.example"
ts.pass = password0
var msg = strings.ReplaceAll(`From: <public@mox.example>
To: <public@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "☺@mox.example"
rcptTo := "public@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), true, true, false)
}
ts.smtperr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7DeliveryUnauth1})
})
}
// Non-member can deliver to public list, not to private list.
func TestAliasDeliverNonMember(t *testing.T) {
resolver := dns.MockResolver{
A: map[string][]string{
"example.org.": {"127.0.0.10"}, // For mx check.
},
PTR: map[string][]string{
"127.0.0.10": {"example.org."}, // To get passed junk filter.
},
}
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
defer ts.close()
var msg = strings.ReplaceAll(`From: <other@example.org>
To: <private@mox.example>
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "other@example.org"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7ExpnProhibited2})
})
msg = strings.ReplaceAll(`From: <private@mox.example>
To: <private@mox.example>
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "private@example.org"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7ExpnProhibited2})
})
msg = strings.ReplaceAll(`From: <other@example.org>
To: <public@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "other@example.org"
rcptTo := "public@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, nil)
ts.checkCount("Inbox", 2) // Receiving for both mjl@ and móx@.
})
}
// Member can deliver to private list, but still not with alias address as message
// from. Message with alias from address as message from is allowed.
func TestAliasDeliverMember(t *testing.T) {
resolver := dns.MockResolver{
A: map[string][]string{
"mox.example.": {"127.0.0.10"}, // For mx check.
},
PTR: map[string][]string{
"127.0.0.10": {"mox.example."}, // To get passed junk filter.
},
TXT: map[string][]string{
"mox.example.": {"v=spf1 ip4:127.0.0.10 -all"}, // To allow multiple recipients in transaction.
},
}
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
defer ts.close()
var msg = strings.ReplaceAll(`From: <mjl@mox.example>
To: <private@mox.example>
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := []string{"private@mox.example", "móx@mox.example"}
if err == nil {
_, err = client.DeliverMultiple(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), true, true, false)
// assuming there wasn't a per-recipient error
}
ts.smtperr(err, nil)
ts.checkCount("Inbox", 0) // Not receiving for mjl@ due to msgfrom, and not móx@ due to rcpt to.
})
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, nil)
ts.checkCount("Inbox", 1) // Only receiving for móx@mox.example, not mjl@.
})
msg = strings.ReplaceAll(`From: <private@mox.example>
To: <private@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "other@mox.example"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, &smtpclient.Error{Permanent: true, Code: smtp.C550MailboxUnavail, Secode: smtp.SePol7ExpnProhibited2})
})
msg = strings.ReplaceAll(`From: <public@mox.example>
To: <public@mox.example>
Subject: test
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "public@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, nil)
})
}
// Message is rejected if no member accepts it.
func TestAliasDeliverReject(t *testing.T) {
resolver := dns.MockResolver{
A: map[string][]string{
"mox.example.": {"127.0.0.10"}, // For mx check.
},
PTR: map[string][]string{
"127.0.0.10": {"mox.example."}, // To get passed junk filter.
},
TXT: map[string][]string{
"mox.example.": {"v=spf1 ip4:127.0.0.10 -all"}, // To allow multiple recipients in transaction.
},
}
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
defer ts.close()
var msg = strings.ReplaceAll(`From: <mjl@mox.example>
To: <private@mox.example>
test email
`, "\n", "\r\n")
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, nil)
ts.checkCount("Inbox", 1) // Only receiving for móx@mox.example, not mjl@.
})
// Mark message as junk.
q := bstore.QueryDB[store.Message](ctxbg, ts.acc.DB)
n, err := q.UpdateFields(map[string]any{"Junk": true})
tcheck(t, err, "mark as junk")
tcompare(t, n, 1)
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
mailFrom := "mjl@mox.example"
rcptTo := "private@mox.example"
if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
}
ts.smtperr(err, &smtpclient.Error{Code: smtp.C451LocalErr, Secode: smtp.SeSys3Other0})
})
}

View file

@ -11,6 +11,7 @@ import (
"github.com/mjl-/bstore"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dmarcrpt"
@ -31,7 +32,10 @@ type delivery struct {
tls bool
m *store.Message
dataFile *os.File
rcptAcc rcptAccount
smtpRcptTo smtp.Path // As used in SMTP, possibly address of alias.
deliverTo smtp.Path // To deliver to, either smtpRcptTo or an alias member address.
destination config.Destination
canonicalAddress string
acc *store.Account
msgTo []message.Address
msgCc []message.Address
@ -44,6 +48,7 @@ type delivery struct {
}
type analysis struct {
d delivery
accept bool
mailbox string
code int
@ -76,6 +81,7 @@ const (
reasonSubjectpass = "subjectpass"
reasonSubjectpassError = "subjectpass-error"
reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
reasonHighRate = "high-rate" // Too many messages, not added to rejects.
)
func isListDomain(d delivery, ld dns.Domain) bool {
@ -93,14 +99,97 @@ func isListDomain(d delivery, ld dns.Domain) bool {
func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
var headers string
mailbox := d.rcptAcc.destination.Mailbox
// We don't want to let a single IP or network deliver too many messages to an
// account. They may fill up the mailbox, either with messages that have to be
// purged, or by filling the disk. We check both cases for IP's and networks.
var rateError bool // Whether returned error represents a rate error.
err := d.acc.DB.Read(ctx, func(tx *bstore.Tx) (retErr error) {
now := time.Now()
defer func() {
log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
}()
checkCount := func(msg store.Message, window time.Duration, limit int) {
if retErr != nil {
return
}
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(msg)
q.FilterGreater("Received", now.Add(-window))
q.FilterEqual("Expunged", false)
n, err := q.Count()
if err != nil {
retErr = err
return
}
if n >= limit {
rateError = true
retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
}
}
checkSize := func(msg store.Message, window time.Duration, limit int64) {
if retErr != nil {
return
}
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(msg)
q.FilterGreater("Received", now.Add(-window))
q.FilterEqual("Expunged", false)
size := d.m.Size
err := q.ForEach(func(v store.Message) error {
size += v.Size
return nil
})
if err != nil {
retErr = err
return
}
if size > limit {
rateError = true
retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
}
}
// todo future: make these configurable
// todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
const day = 24 * time.Hour
checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1MessagesPerMinute)
checkCount(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 20*500)
checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 1500)
checkCount(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 20*1500)
checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 4500)
checkCount(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 20*4500)
const MB = 1024 * 1024
checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, time.Minute, limitIPMasked1SizePerMinute)
checkSize(store.Message{RemoteIPMasked1: d.m.RemoteIPMasked1}, day, 3*1000*MB)
checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, time.Minute, 3000*MB)
checkSize(store.Message{RemoteIPMasked2: d.m.RemoteIPMasked2}, day, 3*3000*MB)
checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, time.Minute, 9000*MB)
checkSize(store.Message{RemoteIPMasked3: d.m.RemoteIPMasked3}, day, 3*9000*MB)
return retErr
})
if err != nil && !rateError {
log.Errorx("checking delivery rates", err)
metricDelivery.WithLabelValues("checkrates", "").Inc()
return analysis{d, false, "", smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, "", headers}
} else if err != nil {
log.Debugx("refusing due to high delivery rate", err)
metricDelivery.WithLabelValues("highrate", "").Inc()
return analysis{d, false, "", smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error(), err, nil, nil, reasonHighRate, "", headers}
}
mailbox := d.destination.Mailbox
if mailbox == "" {
mailbox = "Inbox"
}
// If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
// check it for a pass.
rs := store.MessageRuleset(log, d.rcptAcc.destination, d.m, d.m.MsgPrefix, d.dataFile)
rs := store.MessageRuleset(log, d.destination, d.m, d.m.MsgPrefix, d.dataFile)
if rs != nil {
mailbox = rs.Mailbox
}
@ -108,7 +197,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
// todo: on temporary failures, reject temporarily?
if isListDomain(d, rs.ListAllowDNSDomain) {
d.m.IsMailingList = true
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
}
}
@ -177,7 +266,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
})
})
if mberr != nil {
return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
return analysis{d, false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
}
d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
}
@ -191,7 +280,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
d.m.Seen = true
log.Info("accepting reject to configured mailbox due to ruleset")
}
return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
return analysis{d, accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
}
if d.dmarcUse && d.dmarcResult.Reject {
@ -202,7 +291,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
// If destination is the DMARC reporting mailbox, do additional checks and keep
// track of the report. We'll check reputation, defaulting to accept.
var dmarcReport *dmarcrpt.Feedback
if d.rcptAcc.destination.DMARCReports {
if d.destination.DMARCReports {
// Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
if d.dmarcResult.Status != dmarc.StatusPass {
log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
@ -227,7 +316,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
// Similar to DMARC reporting, we check for the required DKIM. We'll check
// reputation, defaulting to accept.
var tlsReport *tlsrpt.Report
if d.rcptAcc.destination.HostTLSReports || d.rcptAcc.destination.DomainTLSReports {
if d.destination.HostTLSReports || d.destination.DomainTLSReports {
matchesDomain := func(sigDomain dns.Domain) bool {
// RFC seems to require exact DKIM domain match with submitt and message From, we
// also allow msgFrom to be subdomain. ../rfc/8460:322
@ -286,7 +375,6 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
var conclusive bool
var method reputationMethod
var reason string
var err error
d.acc.WithRLock(func() {
err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
if err := assignMailbox(tx); err != nil {
@ -308,12 +396,12 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
slog.String("method", string(method)))
if conclusive {
if !*isjunk {
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
return analysis{d: d, accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
}
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
} else if dmarcReport != nil || tlsReport != nil {
log.Info("accepting message with dmarc aggregate report or tls report without reputation")
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
return analysis{d: d, accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
}
// If there was no previous message from sender or its domain, and we have an SPF
// (soft)fail, reject the message.
@ -340,7 +428,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
var subjectpassKey string
conf, _ := d.acc.Conf()
if conf.SubjectPass.Period > 0 {
subjectpassKey, err = d.acc.Subjectpass(d.rcptAcc.canonicalAddress)
subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
if err != nil {
log.Errorx("get key for verifying subject token", err)
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError)
@ -349,7 +437,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
pass := err == nil
log.Infox("pass by subject token", err, slog.Bool("pass", pass))
if pass {
return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
}
}
@ -380,7 +468,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
rcptToMatch := func(l []message.Address) bool {
// todo: we use Go's net/mail to parse message header addresses. it does not allow empty quoted strings (contrary to spec), leaving To empty. so we don't verify To address for that unusual case for now. ../rfc/5322:961 ../rfc/5322:743
if d.rcptAcc.rcptTo.Localpart == "" {
if d.smtpRcptTo.Localpart == "" {
return true
}
for _, a := range l {
@ -389,7 +477,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
continue
}
lp, err := smtp.ParseLocalpart(a.User)
if err == nil && dom == d.rcptAcc.rcptTo.IPDomain.Domain && lp == d.rcptAcc.rcptTo.Localpart {
if err == nil && dom == d.smtpRcptTo.IPDomain.Domain && lp == d.smtpRcptTo.Localpart {
return true
}
}
@ -413,6 +501,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
// providers (e.g. gmail) does not DKIM-sign Bcc headers, so junk messages can be
// sent with matching Bcc headers. We don't get here for known senders.
threshold = 0.25
log.Print("msgto/cc", slog.Any("msgto", d.msgTo), slog.Any("msgcc", d.msgCc))
log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
reason = reasonJunkContentStrict
}
@ -463,7 +552,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
}
if accept {
return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
return analysis{d: d, accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
}
if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {

View file

@ -336,19 +336,30 @@ type conn struct {
has8bitmime bool // If MAIL FROM parameter BODY=8BITMIME was sent. Required for SMTPUTF8.
smtputf8 bool // todo future: we should keep track of this per recipient. perhaps only a specific recipient requires smtputf8, e.g. due to a utf8 localpart.
msgsmtputf8 bool // Is SMTPUTF8 required for the received message. Default to the same value as `smtputf8`, but is re-evaluated after the whole message (envelope and data) is received.
recipients []rcptAccount
recipients []recipient
}
type rcptAccount struct {
rcptTo smtp.Path
local bool // Whether recipient is a local user.
// Only valid for local delivery.
accountName string
destination config.Destination
canonicalAddress string // Optional catchall part stripped and/or lowercased.
}
type rcptAlias struct {
alias config.Alias
canonicalAddress string // Optional catchall part stripped and/or lowercased.
}
type recipient struct {
addr smtp.Path
// If account and alias are both not set, this is not for a local address. This is
// normal for submission, where messages are added to the queue. For incoming
// deliveries, this will result in an error.
account *rcptAccount // If set, recipient address is for this local account.
alias *rcptAlias // If set, for a local alias.
}
func isClosed(err error) bool {
return errors.Is(err, errIO) || moxio.IsClosed(err)
}
@ -813,7 +824,7 @@ func (c *conn) xneedTLSForDelivery(rcpt smtp.Path) {
}
func isTLSReportRecipient(rcpt smtp.Path) bool {
_, _, dest, err := mox.FindAccount(rcpt.Localpart, rcpt.IPDomain.Domain, false)
_, _, _, dest, err := mox.LookupAddress(rcpt.Localpart, rcpt.IPDomain.Domain, false, false)
return err == nil && (dest.HostTLSReports || dest.DomainTLSReports)
}
@ -1487,7 +1498,7 @@ func (c *conn) cmdMail(p *parser) {
if rpath.IsZero() {
return true
}
accName, _, _, err := mox.FindAccount(rpath.Localpart, rpath.IPDomain.Domain, false)
accName, _, _, _, err := mox.LookupAddress(rpath.Localpart, rpath.IPDomain.Domain, false, false)
return err == nil && accName == c.account.Name
}
@ -1626,10 +1637,15 @@ func (c *conn) cmdRcpt(p *parser) {
if !c.submission {
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip")
}
c.recipients = append(c.recipients, rcptAccount{fpath, false, "", config.Destination{}, ""})
} else if accountName, canonical, addr, err := mox.FindAccount(fpath.Localpart, fpath.IPDomain.Domain, true); err == nil {
// note: a bare postmaster, without domain, is handled by FindAccount. ../rfc/5321:735
c.recipients = append(c.recipients, rcptAccount{fpath, true, accountName, addr, canonical})
c.recipients = append(c.recipients, recipient{fpath, nil, nil})
} else if accountName, alias, canonical, addr, err := mox.LookupAddress(fpath.Localpart, fpath.IPDomain.Domain, true, true); err == nil {
// note: a bare postmaster, without domain, is handled by LookupAddress. ../rfc/5321:735
if alias != nil {
c.recipients = append(c.recipients, recipient{fpath, nil, &rcptAlias{*alias, canonical}})
} else {
c.recipients = append(c.recipients, recipient{fpath, &rcptAccount{accountName, addr, canonical}, nil})
}
} else if Localserve {
// If the address isn't known, and we are in localserve, deliver to the mox user.
// If account or destination doesn't exist, it will be handled during delivery. For
@ -1637,14 +1653,14 @@ func (c *conn) cmdRcpt(p *parser) {
// which is typically the mox user.
acc, _ := mox.Conf.Account("mox")
dest := acc.Destinations["mox@localhost"]
c.recipients = append(c.recipients, rcptAccount{fpath, true, "mox", dest, "mox@localhost"})
c.recipients = append(c.recipients, recipient{fpath, &rcptAccount{"mox", dest, "mox@localhost"}, nil})
} else if errors.Is(err, mox.ErrDomainNotFound) {
if !c.submission {
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain")
}
// We'll be delivering this email.
c.recipients = append(c.recipients, rcptAccount{fpath, false, "", config.Destination{}, ""})
} else if errors.Is(err, mox.ErrAccountNotFound) {
c.recipients = append(c.recipients, recipient{fpath, nil, nil})
} else if errors.Is(err, mox.ErrAddressNotFound) {
if c.submission {
// For submission, we're transparent about which user exists. Should be fine for the typical small-scale deploy.
// ../rfc/5321:1071
@ -1653,7 +1669,7 @@ func (c *conn) cmdRcpt(p *parser) {
// We pretend to accept. We don't want to let remote know the user does not exist
// until after DATA. Because then remote has committed to sending a message.
// note: not local for !c.submission is the signal this address is in error.
c.recipients = append(c.recipients, rcptAccount{fpath, false, "", config.Destination{}, ""})
c.recipients = append(c.recipients, recipient{fpath, nil, nil})
} else {
c.log.Errorx("looking up account for delivery", err, slog.Any("rcptto", fpath))
xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "error processing")
@ -1696,7 +1712,7 @@ func (c *conn) isSMTPUTF8Required(part *message.Part) bool {
}
// Check all "RCPT TO".
for _, rcpt := range c.recipients {
if hasNonASCII(strings.NewReader(string(rcpt.rcptTo.Localpart))) {
if hasNonASCII(strings.NewReader(string(rcpt.addr.Localpart))) {
return true
}
}
@ -1961,15 +1977,11 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
c.log.Infox("parsing message From address", err, slog.String("user", c.username))
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeMsg6Other0, "cannot parse header or From address: %v", err)
}
accName, _, _, err := mox.FindAccount(msgFrom.Localpart, msgFrom.Domain, true)
if err != nil || accName != c.account.Name {
if !mox.AllowMsgFrom(c.account.Name, msgFrom) {
// ../rfc/6409:522
if err == nil {
err = mox.ErrAccountNotFound
}
metricSubmission.WithLabelValues("badfrom").Inc()
c.log.Infox("verifying message From address", err, slog.String("user", c.username), slog.Any("msgfrom", msgFrom))
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user")
c.log.Infox("verifying message from address", mox.ErrAddressNotFound, slog.String("user", c.username), slog.Any("msgfrom", msgFrom))
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "message from address must belong to authenticated user")
}
// TLS-Required: No header makes us not enforce recipient domain's TLS policy.
@ -2005,7 +2017,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
err = c.account.DB.Read(ctx, func(tx *bstore.Tx) error {
rcpts := make([]smtp.Path, len(c.recipients))
for i, r := range c.recipients {
rcpts[i] = r.rcptTo
rcpts[i] = r.addr
}
msglimit, rcptlimit, err := c.account.SendLimitReached(tx, rcpts)
xcheckf(err, "checking sender limit")
@ -2051,9 +2063,8 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
selectors := mox.DKIMSelectors(confDom.DKIM)
if len(selectors) > 0 {
if canonical, err := mox.CanonicalLocalpart(msgFrom.Localpart, confDom); err != nil {
c.log.Errorx("determining canonical localpart for dkim signing", err, slog.Any("localpart", msgFrom.Localpart))
} else if dkimHeaders, err := dkim.Sign(ctx, c.log.Logger, canonical, msgFrom.Domain, selectors, c.msgsmtputf8, store.FileMsgReader(msgPrefix, dataFile)); err != nil {
canonical := mox.CanonicalLocalpart(msgFrom.Localpart, confDom)
if dkimHeaders, err := dkim.Sign(ctx, c.log.Logger, canonical, msgFrom.Domain, selectors, c.msgsmtputf8, store.FileMsgReader(msgPrefix, dataFile)); err != nil {
c.log.Errorx("dkim sign for domain", err, slog.Any("domain", msgFrom.Domain))
metricServerErrors.WithLabelValues("dkimsign").Inc()
} else {
@ -2091,9 +2102,9 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
}
now := time.Now()
qml := make([]queue.Msg, len(c.recipients))
for i, rcptAcc := range c.recipients {
for i, rcpt := range c.recipients {
if Localserve {
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart)
code, timeout := mox.LocalserveNeedsError(rcpt.addr.Localpart)
if timeout {
c.log.Info("timing out submission due to special localpart")
mox.Sleep(mox.Context, time.Hour)
@ -2116,11 +2127,11 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
// messages in a single smtp transaction.
var rcptTo string
if len(c.recipients) == 1 {
rcptTo = rcptAcc.rcptTo.String()
rcptTo = rcpt.addr.String()
}
xmsgPrefix := append([]byte(recvHdrFor(rcptTo)), msgPrefix...)
msgSize := int64(len(xmsgPrefix)) + msgWriter.Size
qm := queue.MakeMsg(fp, rcptAcc.rcptTo, msgWriter.Has8bit, c.msgsmtputf8, msgSize, messageID, xmsgPrefix, c.requireTLS, now, header.Get("Subject"))
qm := queue.MakeMsg(fp, rcpt.addr, msgWriter.Has8bit, c.msgsmtputf8, msgSize, messageID, xmsgPrefix, c.requireTLS, now, header.Get("Subject"))
if !c.futureRelease.IsZero() {
qm.NextAttempt = c.futureRelease
qm.FutureReleaseRequest = c.futureReleaseRequest
@ -2139,18 +2150,18 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
xsmtpServerErrorf(errCodes(smtp.C451LocalErr, smtp.SeSys3Other0, err), "error delivering message: %v", err)
}
metricSubmission.WithLabelValues("ok").Inc()
for i, rcptAcc := range c.recipients {
for i, rcpt := range c.recipients {
c.log.Info("messages queued for delivery",
slog.Any("mailfrom", *c.mailFrom),
slog.Any("rcptto", rcptAcc.rcptTo),
slog.Any("rcptto", rcpt.addr),
slog.Bool("smtputf8", c.smtputf8),
slog.Bool("msgsmtputf8", c.msgsmtputf8),
slog.Int64("msgsize", qml[i].Size))
}
err = c.account.DB.Write(ctx, func(tx *bstore.Tx) error {
for _, rcptAcc := range c.recipients {
outgoing := store.Outgoing{Recipient: rcptAcc.rcptTo.XString(true)}
for _, rcpt := range c.recipients {
outgoing := store.Outgoing{Recipient: rcpt.addr.XString(true)}
if err := tx.Insert(&outgoing); err != nil {
return fmt.Errorf("adding outgoing message: %v", err)
}
@ -2370,7 +2381,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// Give immediate response if all recipients are unknown.
nunknown := 0
for _, r := range c.recipients {
if !r.local {
if r.account == nil && r.alias == nil {
nunknown++
}
}
@ -2604,8 +2615,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
errmsg string
}
var deliverErrors []deliverError
addError := func(rcptAcc rcptAccount, code int, secode string, userError bool, errmsg string) {
e := deliverError{rcptAcc.rcptTo, code, secode, userError, errmsg}
addError := func(rcpt recipient, code int, secode string, userError bool, errmsg string) {
e := deliverError{rcpt.addr, code, secode, userError, errmsg}
c.log.Info("deliver error",
slog.Any("rcptto", e.rcptTo),
slog.Int("code", code),
@ -2615,124 +2626,52 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
deliverErrors = append(deliverErrors, e)
}
// For each recipient, do final spam analysis and delivery.
for _, rcptAcc := range c.recipients {
log := c.log.With(slog.Any("mailfrom", c.mailFrom), slog.Any("rcptto", rcptAcc.rcptTo))
// Sort recipients: local accounts, aliases, unknown. For ensuring we don't deliver
// to an alias destination that was also explicitly sent to.
rcptScore := func(r recipient) int {
if r.account != nil {
return 0
} else if r.alias != nil {
return 1
}
return 2
}
sort.SliceStable(c.recipients, func(i, j int) bool {
return rcptScore(c.recipients[i]) < rcptScore(c.recipients[j])
})
// If this is not a valid local user, we send back a DSN. This can only happen when
// there are also valid recipients, and only when remote is SPF-verified, so the DSN
// should not cause backscatter.
// In case of serious errors, we abort the transaction. We may have already
// delivered some messages. Perhaps it would be better to continue with other
// deliveries, and return an error at the end? Though the failure conditions will
// probably prevent any other successful deliveries too...
// We'll continue delivering to other recipients. ../rfc/5321:3275
if !rcptAcc.local {
metricDelivery.WithLabelValues("unknownuser", "").Inc()
addError(rcptAcc, smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, true, "no such user")
continue
// Return whether address is a regular explicit recipient in this transaction. Used
// to prevent delivering a message to an address both for alias and explicit
// addressee. Relies on c.recipients being sorted as above.
regularRecipient := func(addr smtp.Path) bool {
for _, rcpt := range c.recipients {
if rcpt.account == nil {
break
} else if rcpt.addr.Equal(addr) {
return true
}
}
return false
}
acc, err := store.OpenAccount(log, rcptAcc.accountName)
// Prepare a message, analyze it against account's junk filter.
// The returned analysis has an open account that must be closed by the caller.
// We call this for all alias destinations, also when we already delivered to that
// recipient: It may be the only recipient that would allow the message.
messageAnalyze := func(log mlog.Log, smtpRcptTo, deliverTo smtp.Path, accountName string, destination config.Destination, canonicalAddr string) (a *analysis, rerr error) {
acc, err := store.OpenAccount(log, accountName)
if err != nil {
log.Errorx("open account", err, slog.Any("account", rcptAcc.accountName))
log.Errorx("open account", err, slog.Any("account", accountName))
metricDelivery.WithLabelValues("accounterror", "").Inc()
addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
continue
return nil, err
}
defer func() {
if acc != nil {
if a == nil {
err := acc.Close()
log.Check(err, "closing account after delivery")
log.Check(err, "closing account during analysis")
}
}()
// We don't want to let a single IP or network deliver too many messages to an
// account. They may fill up the mailbox, either with messages that have to be
// purged, or by filling the disk. We check both cases for IP's and networks.
var rateError bool // Whether returned error represents a rate error.
err = acc.DB.Read(ctx, func(tx *bstore.Tx) (retErr error) {
now := time.Now()
defer func() {
log.Debugx("checking message and size delivery rates", retErr, slog.Duration("duration", time.Since(now)))
}()
checkCount := func(msg store.Message, window time.Duration, limit int) {
if retErr != nil {
return
}
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(msg)
q.FilterGreater("Received", now.Add(-window))
q.FilterEqual("Expunged", false)
n, err := q.Count()
if err != nil {
retErr = err
return
}
if n >= limit {
rateError = true
retErr = fmt.Errorf("more than %d messages in past %s from your ip/network", limit, window)
}
}
checkSize := func(msg store.Message, window time.Duration, limit int64) {
if retErr != nil {
return
}
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(msg)
q.FilterGreater("Received", now.Add(-window))
q.FilterEqual("Expunged", false)
size := msgWriter.Size
err := q.ForEach(func(v store.Message) error {
size += v.Size
return nil
})
if err != nil {
retErr = err
return
}
if size > limit {
rateError = true
retErr = fmt.Errorf("more than %d bytes in past %s from your ip/network", limit, window)
}
}
// todo future: make these configurable
// todo: should we have a limit for forwarded messages? they are stored with empty RemoteIPMasked*
const day = 24 * time.Hour
checkCount(store.Message{RemoteIPMasked1: ipmasked1}, time.Minute, limitIPMasked1MessagesPerMinute)
checkCount(store.Message{RemoteIPMasked1: ipmasked1}, day, 20*500)
checkCount(store.Message{RemoteIPMasked2: ipmasked2}, time.Minute, 1500)
checkCount(store.Message{RemoteIPMasked2: ipmasked2}, day, 20*1500)
checkCount(store.Message{RemoteIPMasked3: ipmasked3}, time.Minute, 4500)
checkCount(store.Message{RemoteIPMasked3: ipmasked3}, day, 20*4500)
const MB = 1024 * 1024
checkSize(store.Message{RemoteIPMasked1: ipmasked1}, time.Minute, limitIPMasked1SizePerMinute)
checkSize(store.Message{RemoteIPMasked1: ipmasked1}, day, 3*1000*MB)
checkSize(store.Message{RemoteIPMasked2: ipmasked2}, time.Minute, 3000*MB)
checkSize(store.Message{RemoteIPMasked2: ipmasked2}, day, 3*3000*MB)
checkSize(store.Message{RemoteIPMasked3: ipmasked3}, time.Minute, 9000*MB)
checkSize(store.Message{RemoteIPMasked3: ipmasked3}, day, 3*9000*MB)
return retErr
})
if err != nil && !rateError {
log.Errorx("checking delivery rates", err)
metricDelivery.WithLabelValues("checkrates", "").Inc()
addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
continue
} else if err != nil {
log.Debugx("refusing due to high delivery rate", err)
metricDelivery.WithLabelValues("highrate", "").Inc()
c.setSlow(true)
addError(rcptAcc, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, err.Error())
continue
}
m := store.Message{
Received: time.Now(),
RemoteIP: c.remoteIP.String(),
@ -2743,8 +2682,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
MailFrom: c.mailFrom.String(),
MailFromLocalpart: c.mailFrom.Localpart,
MailFromDomain: c.mailFrom.IPDomain.Domain.Name(),
RcptToLocalpart: rcptAcc.rcptTo.Localpart,
RcptToDomain: rcptAcc.rcptTo.IPDomain.Domain.Name(),
RcptToLocalpart: smtpRcptTo.Localpart,
RcptToDomain: smtpRcptTo.IPDomain.Domain.Name(),
MsgFromLocalpart: msgFrom.Localpart,
MsgFromDomain: msgFrom.Domain.Name(),
MsgFromOrgDomain: publicsuffix.Lookup(ctx, log.Logger, msgFrom.Domain).Name(),
@ -2774,8 +2713,90 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
msgTo = envelope.To
msgCc = envelope.CC
}
d := delivery{c.tls, &m, dataFile, rcptAcc, acc, msgTo, msgCc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
a := analyze(ctx, log, c.resolver, d)
d := delivery{c.tls, &m, dataFile, smtpRcptTo, deliverTo, destination, canonicalAddr, acc, msgTo, msgCc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
r := analyze(ctx, log, c.resolver, d)
return &r, nil
}
// Either deliver the message, or call addError to register the recipient as failed.
// If recipient is an alias, we may be delivering to multiple address/accounts and
// we will consider a message delivered if we delivered it to at least one account
// (others may be over quota).
processRecipient := func(rcpt recipient) {
log := c.log.With(slog.Any("mailfrom", c.mailFrom), slog.Any("rcptto", rcpt.addr))
// If this is not a valid local user, we send back a DSN. This can only happen when
// there are also valid recipients, and only when remote is SPF-verified, so the DSN
// should not cause backscatter.
// In case of serious errors, we abort the transaction. We may have already
// delivered some messages. Perhaps it would be better to continue with other
// deliveries, and return an error at the end? Though the failure conditions will
// probably prevent any other successful deliveries too...
// We'll continue delivering to other recipients. ../rfc/5321:3275
if rcpt.account == nil && rcpt.alias == nil {
metricDelivery.WithLabelValues("unknownuser", "").Inc()
addError(rcpt, smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, true, "no such user")
return
}
// la holds all analysis, and message preparation, for all accounts (multiple for
// aliases). Each has an open account that we we close on return.
var la []analysis
defer func() {
for _, a := range la {
err := a.d.acc.Close()
log.Check(err, "close account")
}
}()
// For aliases, we prepare & analyze for each recipient. We accept the message if
// any recipient accepts it. Regular destination have just a single account to
// check. We check all alias destinations, even if we already explicitly delivered
// to them: they may be the only destination that would accept the message.
var a0 *analysis // Analysis we've used for accept/reject decision.
if rcpt.alias != nil {
// Check if msgFrom address is acceptable. This doesn't take validation into
// consideration. If the header was forged, the message may be rejected later on.
if !aliasAllowedMsgFrom(rcpt.alias.alias, msgFrom) {
addError(rcpt, smtp.C550MailboxUnavail, smtp.SePol7ExpnProhibited2, true, "not allowed to send to destination")
return
}
la = make([]analysis, 0, len(rcpt.alias.alias.ParsedAddresses))
for _, aa := range rcpt.alias.alias.ParsedAddresses {
a, err := messageAnalyze(log, rcpt.addr, aa.Address.Path(), aa.AccountName, aa.Destination, rcpt.alias.canonicalAddress)
if err != nil {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
return
}
la = append(la, *a)
if a.accept && a0 == nil {
// Address that caused us to accept.
a0 = &la[len(la)-1]
}
}
if a0 == nil {
// First address, for rejecting.
a0 = &la[0]
}
} else {
a, err := messageAnalyze(log, rcpt.addr, rcpt.addr, rcpt.account.accountName, rcpt.account.destination, rcpt.account.canonicalAddress)
if err != nil {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
return
}
la = []analysis{*a}
a0 = &la[0]
}
if !a0.accept && a0.reason == reasonHighRate {
log.Info("incoming message rejected for high rate, not storing in rejects mailbox", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom))
metricDelivery.WithLabelValues("reject", a0.reason).Inc()
c.setSlow(true)
addError(rcpt, a0.code, a0.secode, a0.userError, a0.errmsg)
return
}
// Any DMARC result override is stored in the evaluation for outgoing DMARC
// aggregate reports, and added to the Authentication-Results message header.
@ -2783,8 +2804,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// they don't overestimate the potential damage of switching from p=none to
// p=reject.
var dmarcOverrides []string
if a.dmarcOverrideReason != "" {
dmarcOverrides = []string{a.dmarcOverrideReason}
if a0.dmarcOverrideReason != "" {
dmarcOverrides = []string{a0.dmarcOverrideReason}
}
if dmarcResult.Record != nil && !dmarcUse {
dmarcOverrides = append(dmarcOverrides, string(dmarcrpt.PolicyOverrideSampledOut))
@ -2806,22 +2827,24 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// Prepend reason as message header, for easy display in mail clients.
var xmox string
if a.reason != "" {
xmox = "X-Mox-Reason: " + a.reason + "\r\n"
if a0.reason != "" {
xmox = "X-Mox-Reason: " + a0.reason + "\r\n"
}
xmox += a.headers
xmox += a0.headers
for i := range la {
// ../rfc/5321:3204
// Received-SPF header goes before Received. ../rfc/7208:2038
m.MsgPrefix = []byte(
la[i].d.m.MsgPrefix = []byte(
xmox +
"Delivered-To: " + rcptAcc.rcptTo.XString(c.msgsmtputf8) + "\r\n" + // ../rfc/9228:274
"Delivered-To: " + la[i].d.deliverTo.XString(c.msgsmtputf8) + "\r\n" + // ../rfc/9228:274
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
rcptAuthResults.Header() +
receivedSPF.Header() +
recvHdrFor(rcptAcc.rcptTo.String()),
recvHdrFor(rcpt.addr.String()),
)
m.Size += int64(len(m.MsgPrefix))
la[i].d.m.Size += int64(len(la[i].d.m.MsgPrefix))
}
// Store DMARC evaluation for inclusion in an aggregate report. Only if there is at
// least one reporting address: We don't want to needlessly store a row in a
@ -2830,24 +2853,24 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// the analysis, we will report on rejects because of DMARC, because it could be
// valuable feedback about forwarded or mailing list messages.
// ../rfc/7489:1492
if !mox.Conf.Static.NoOutgoingDMARCReports && dmarcResult.Record != nil && len(dmarcResult.Record.AggregateReportAddresses) > 0 && (a.accept && !m.IsReject || a.reason == reasonDMARCPolicy) {
if !mox.Conf.Static.NoOutgoingDMARCReports && dmarcResult.Record != nil && len(dmarcResult.Record.AggregateReportAddresses) > 0 && (a0.accept && !a0.d.m.IsReject || a0.reason == reasonDMARCPolicy) {
// Disposition holds our decision on whether to accept the message. Not what the
// DMARC evaluation resulted in. We can override, e.g. because of mailing lists,
// forwarding, or local policy.
// We treat quarantine as reject, so never claim to quarantine.
// ../rfc/7489:1691
disposition := dmarcrpt.DispositionNone
if !a.accept {
if !a0.accept {
disposition = dmarcrpt.DispositionReject
}
// unknownDomain returns whether the sender is domain with which this account has
// not had positive interaction.
unknownDomain := func() (unknown bool) {
err := acc.DB.Read(ctx, func(tx *bstore.Tx) (err error) {
err := a0.d.acc.DB.Read(ctx, func(tx *bstore.Tx) (err error) {
// See if we received a non-junk message from this organizational domain.
q := bstore.QueryTx[store.Message](tx)
q.FilterNonzero(store.Message{MsgFromOrgDomain: m.MsgFromOrgDomain})
q.FilterNonzero(store.Message{MsgFromOrgDomain: a0.d.m.MsgFromOrgDomain})
q.FilterEqual("Notjunk", true)
q.FilterEqual("IsReject", false)
exists, err := q.Exists()
@ -2860,7 +2883,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// See if we sent a message to this organizational domain.
qr := bstore.QueryTx[store.Recipient](tx)
qr.FilterNonzero(store.Recipient{OrgDomain: m.MsgFromOrgDomain})
qr.FilterNonzero(store.Recipient{OrgDomain: a0.d.m.MsgFromOrgDomain})
exists, err = qr.Exists()
if err != nil {
return fmt.Errorf("querying for message sent to organizational domain: %v", err)
@ -2894,7 +2917,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// loop. We also don't want to be used for sending reports to unsuspecting domains
// we have no relation with.
// todo: would it make sense to also mark some percentage of mailing-list-policy-overrides optional? to lower the load on mail servers of folks sending to large mailing lists.
Optional: rcptAcc.destination.DMARCReports || rcptAcc.destination.HostTLSReports || rcptAcc.destination.DomainTLSReports || a.reason == reasonDMARCPolicy && unknownDomain(),
Optional: a0.d.destination.DMARCReports || a0.d.destination.HostTLSReports || a0.d.destination.DomainTLSReports || a0.reason == reasonDMARCPolicy && unknownDomain(),
Addresses: addresses,
@ -2911,7 +2934,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
Disposition: disposition,
AlignedDKIMPass: dmarcResult.AlignedDKIMPass,
AlignedSPFPass: dmarcResult.AlignedSPFPass,
EnvelopeTo: rcptAcc.rcptTo.IPDomain.String(),
EnvelopeTo: rcpt.addr.IPDomain.String(),
EnvelopeFrom: c.mailFrom.IPDomain.String(),
HeaderFrom: msgFrom.Domain.Name(),
}
@ -2956,29 +2979,41 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
log.Check(err, "adding dmarc evaluation to database for aggregate report")
}
conf, _ := acc.Conf()
if !a.accept {
if conf.RejectsMailbox != "" {
present, _, messagehash, err := rejectPresent(log, acc, conf.RejectsMailbox, &m, dataFile)
if !a0.accept {
for _, a := range la {
// Don't add message if address was also explicitly present in a RCPT TO command.
if rcpt.alias != nil && regularRecipient(a.d.deliverTo) {
continue
}
conf, _ := a.d.acc.Conf()
if conf.RejectsMailbox == "" {
continue
}
present, _, messagehash, err := rejectPresent(log, a.d.acc, conf.RejectsMailbox, a.d.m, dataFile)
if err != nil {
log.Errorx("checking whether reject is already present", err)
} else if !present {
m.IsReject = true
m.Seen = true // We don't want to draw attention.
continue
} else if present {
log.Info("reject message is already present, ignoring")
continue
}
a.d.m.IsReject = true
a.d.m.Seen = true // We don't want to draw attention.
// Regular automatic junk flags configuration applies to these messages. The
// default is to treat these as neutral, so they won't cause outright rejections
// due to reputation for later delivery attempts.
m.MessageHash = messagehash
acc.WithWLock(func() {
a.d.m.MessageHash = messagehash
a.d.acc.WithWLock(func() {
hasSpace := true
var err error
if !conf.KeepRejects {
hasSpace, err = acc.TidyRejectsMailbox(c.log, conf.RejectsMailbox)
hasSpace, err = a.d.acc.TidyRejectsMailbox(c.log, conf.RejectsMailbox)
}
if err != nil {
log.Errorx("tidying rejects mailbox", err)
} else if hasSpace {
if err := acc.DeliverMailbox(log, conf.RejectsMailbox, &m, dataFile); err != nil {
if err := a.d.acc.DeliverMailbox(log, conf.RejectsMailbox, a.d.m, dataFile); err != nil {
log.Errorx("delivering spammy mail to rejects mailbox", err)
} else {
log.Info("delivered spammy mail to rejects mailbox")
@ -2987,36 +3022,33 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
log.Info("not storing spammy mail to full rejects mailbox")
}
})
} else {
log.Info("reject message is already present, ignoring")
}
}
log.Info("incoming message rejected", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom))
metricDelivery.WithLabelValues("reject", a.reason).Inc()
log.Info("incoming message rejected", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom))
metricDelivery.WithLabelValues("reject", a0.reason).Inc()
c.setSlow(true)
addError(rcptAcc, a.code, a.secode, a.userError, a.errmsg)
continue
addError(rcpt, a0.code, a0.secode, a0.userError, a0.errmsg)
return
}
delayFirstTime := true
if a.dmarcReport != nil {
if rcpt.account != nil && a0.dmarcReport != nil {
// todo future: add rate limiting to prevent DoS attacks. ../rfc/7489:2570
if err := dmarcdb.AddReport(ctx, a.dmarcReport, msgFrom.Domain); err != nil {
if err := dmarcdb.AddReport(ctx, a0.dmarcReport, msgFrom.Domain); err != nil {
log.Errorx("saving dmarc aggregate report in database", err)
} else {
log.Info("dmarc aggregate report processed")
m.Flags.Seen = true
a0.d.m.Flags.Seen = true
delayFirstTime = false
}
}
if a.tlsReport != nil {
if rcpt.account != nil && a0.tlsReport != nil {
// todo future: add rate limiting to prevent DoS attacks.
if err := tlsrptdb.AddReport(ctx, c.log, msgFrom.Domain, c.mailFrom.String(), rcptAcc.destination.HostTLSReports, a.tlsReport); err != nil {
if err := tlsrptdb.AddReport(ctx, c.log, msgFrom.Domain, c.mailFrom.String(), a0.d.destination.HostTLSReports, a0.tlsReport); err != nil {
log.Errorx("saving TLSRPT report in database", err)
} else {
log.Info("tlsrpt report processed")
m.Flags.Seen = true
a0.d.m.Flags.Seen = true
delayFirstTime = false
}
}
@ -3024,24 +3056,14 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// If this is a first-time sender and not a forwarded/mailing list message, wait
// before actually delivering. If this turns out to be a spammer, we've kept one of
// their connections busy.
if delayFirstTime && !m.IsForward && !m.IsMailingList && a.reason == reasonNoBadSignals && !conf.NoFirstTimeSenderDelay && c.firstTimeSenderDelay > 0 {
a0conf, _ := a0.d.acc.Conf()
if delayFirstTime && !a0.d.m.IsForward && !a0.d.m.IsMailingList && a0.reason == reasonNoBadSignals && !a0conf.NoFirstTimeSenderDelay && c.firstTimeSenderDelay > 0 {
log.Debug("delaying before delivering from sender without reputation", slog.Duration("delay", c.firstTimeSenderDelay))
mox.Sleep(mox.Context, c.firstTimeSenderDelay)
}
// Gather the message-id before we deliver and the file may be consumed.
if !parsedMessageID {
if p, err := message.Parse(c.log.Logger, false, store.FileMsgReader(m.MsgPrefix, dataFile)); err != nil {
log.Infox("parsing message for message-id", err)
} else if header, err := p.Header(); err != nil {
log.Infox("parsing message header for message-id", err)
} else {
messageID = header.Get("Message-Id")
}
}
if Localserve {
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart)
code, timeout := mox.LocalserveNeedsError(rcpt.addr.Localpart)
if timeout {
log.Info("timing out due to special localpart")
mox.Sleep(mox.Context, time.Hour)
@ -3049,28 +3071,55 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
} else if code != 0 {
log.Info("failure due to special localpart", slog.Int("code", code))
metricDelivery.WithLabelValues("delivererror", "localserve").Inc()
addError(rcptAcc, code, smtp.SeOther00, false, fmt.Sprintf("failure with code %d due to special localpart", code))
addError(rcpt, code, smtp.SeOther00, false, fmt.Sprintf("failure with code %d due to special localpart", code))
return
}
}
var delivered bool
acc.WithWLock(func() {
if err := acc.DeliverMailbox(log, a.mailbox, &m, dataFile); err != nil {
log.Errorx("delivering", err)
metricDelivery.WithLabelValues("delivererror", a.reason).Inc()
if errors.Is(err, store.ErrOverQuota) {
addError(rcptAcc, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, "account storage full")
// Gather the message-id before we deliver and the file may be consumed.
if !parsedMessageID {
if p, err := message.Parse(c.log.Logger, false, store.FileMsgReader(a0.d.m.MsgPrefix, dataFile)); err != nil {
log.Infox("parsing message for message-id", err)
} else if header, err := p.Header(); err != nil {
log.Infox("parsing message header for message-id", err)
} else {
addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
messageID = header.Get("Message-Id")
}
parsedMessageID = true
}
// Finally deliver the message to the account(s).
var nerr int // Number of non-quota errors.
var nfull int // Number of failed deliveries due to over quota.
var ndelivered int // Number delivered to account.
for _, a := range la {
// Don't deliver to recipient that was explicitly present in SMTP transaction, or
// is sending the message to an alias they are member of.
if rcpt.alias != nil && (regularRecipient(a.d.deliverTo) || a.d.deliverTo.Equal(msgFrom.Path())) {
continue
}
var delivered bool
a.d.acc.WithWLock(func() {
if err := a.d.acc.DeliverMailbox(log, a.mailbox, a.d.m, dataFile); err != nil {
log.Errorx("delivering", err)
metricDelivery.WithLabelValues("delivererror", a0.reason).Inc()
if errors.Is(err, store.ErrOverQuota) {
nfull++
} else {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
nerr++
}
return
}
delivered = true
metricDelivery.WithLabelValues("delivered", a.reason).Inc()
log.Info("incoming message delivered", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom))
ndelivered++
metricDelivery.WithLabelValues("delivered", a0.reason).Inc()
log.Info("incoming message delivered", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom))
conf, _ = acc.Conf()
if conf.RejectsMailbox != "" && m.MessageID != "" {
if err := acc.RejectsRemove(log, conf.RejectsMailbox, m.MessageID); err != nil {
conf, _ := a.d.acc.Conf()
if conf.RejectsMailbox != "" && a.d.m.MessageID != "" {
if err := a.d.acc.RejectsRemove(log, conf.RejectsMailbox, a.d.m.MessageID); err != nil {
log.Errorx("removing message from rejects mailbox", err, slog.String("messageid", messageID))
}
}
@ -3078,19 +3127,32 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// Pass delivered messages to queue for DSN processing and/or hooks.
if delivered {
mr := store.FileMsgReader(m.MsgPrefix, dataFile)
part, err := m.LoadPart(mr)
mr := store.FileMsgReader(a.d.m.MsgPrefix, dataFile)
part, err := a.d.m.LoadPart(mr)
if err != nil {
log.Errorx("loading parsed part for evaluating webhook", err)
} else {
err = queue.Incoming(context.Background(), log, acc, messageID, m, part, a.mailbox)
err = queue.Incoming(context.Background(), log, a.d.acc, messageID, *a.d.m, part, a.mailbox)
log.Check(err, "queueing webhook for incoming delivery")
}
} else if nerr > 0 && ndelivered == 0 {
// Don't continue if we had an error and haven't delivered yet. If we only had
// quota-related errors, we keep trying for an account to deliver to.
break
}
}
if ndelivered == 0 && (nerr > 0 || nfull > 0) {
if nerr == 0 {
addError(rcpt, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, "account storage full")
} else {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
}
}
}
err = acc.Close()
log.Check(err, "closing account after delivering")
acc = nil
// For each recipient, do final spam analysis and delivery.
for _, rcpt := range c.recipients {
processRecipient(rcpt)
}
// If all recipients failed to deliver, return an error.
@ -3184,6 +3246,21 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
c.writecodeline(smtp.C250Completed, smtp.SeMailbox2Other0, "it is done", nil)
}
// Return whether msgFrom address is allowed to send a message to alias.
func aliasAllowedMsgFrom(alias config.Alias, msgFrom smtp.Address) bool {
for _, aa := range alias.ParsedAddresses {
if aa.Address == msgFrom {
return true
}
}
lp, err := smtp.ParseLocalpart(alias.LocalpartStr)
xcheckf(err, "parsing alias localpart")
if msgFrom == smtp.NewAddress(lp, alias.Domain) {
return alias.AllowMsgFrom
}
return alias.PostPublic
}
// ecode returns either ecode, or a more specific error based on err.
// For example, ecode can be turned from an "other system" error into a "mail
// system full" if the error indicates no disk space is available.
@ -3247,6 +3324,8 @@ func (c *conn) cmdExpn(p *parser) {
}
p.xend()
// todo: we could implement expn for local aliases for authenticated users, when members have permission to list. would anyone use it?
// ../rfc/5321:4239
xsmtpUserErrorf(smtp.C252WithoutVrfy, smtp.SePol7Other0, "no expand but will try delivery")
}

View file

@ -116,11 +116,13 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test
mox.MustLoadConfig(true, false)
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
os.RemoveAll(dataDir)
var err error
ts.acc, err = store.OpenAccount(log, "mjl")
tcheck(t, err, "open account")
err = ts.acc.SetPassword(log, password0)
tcheck(t, err, "set password")
ts.switchStop = store.Switchboard()
err = queue.Init()
tcheck(t, err, "queue init")
@ -143,6 +145,23 @@ func (ts *testserver) close() {
ts.acc = nil
}
func (ts *testserver) checkCount(mailboxName string, expect int) {
t := ts.t
t.Helper()
q := bstore.QueryDB[store.Mailbox](ctxbg, ts.acc.DB)
q.FilterNonzero(store.Mailbox{Name: mailboxName})
mb, err := q.Get()
tcheck(t, err, "get mailbox")
qm := bstore.QueryDB[store.Message](ctxbg, ts.acc.DB)
qm.FilterNonzero(store.Message{MailboxID: mb.ID})
qm.FilterEqual("Expunged", false)
n, err := qm.Count()
tcheck(t, err, "count messages in mailbox")
if n != expect {
t.Fatalf("messages in mailbox, found %d, expected %d", n, expect)
}
}
func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
ts.t.Helper()
ts.runRaw(func(conn net.Conn) {
@ -194,6 +213,14 @@ func (ts *testserver) runRaw(fn func(clientConn net.Conn)) {
fn(clientConn)
}
func (ts *testserver) smtperr(err error, expErr *smtpclient.Error) {
ts.t.Helper()
var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) {
ts.t.Fatalf("got err %#v (%q), expected %#v", err, err, expErr)
}
}
// Just a cert that appears valid. SMTP client will not verify anything about it
// (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
// one moment where it makes life easier.
@ -508,22 +535,6 @@ func TestSpam(t *testing.T) {
tinsertmsg(t, ts.acc, "Inbox", &nm, deliverMessage)
}
checkCount := func(mailboxName string, expect int) {
t.Helper()
q := bstore.QueryDB[store.Mailbox](ctxbg, ts.acc.DB)
q.FilterNonzero(store.Mailbox{Name: mailboxName})
mb, err := q.Get()
tcheck(t, err, "get rejects mailbox")
qm := bstore.QueryDB[store.Message](ctxbg, ts.acc.DB)
qm.FilterNonzero(store.Message{MailboxID: mb.ID})
qm.FilterEqual("Expunged", false)
n, err := qm.Count()
tcheck(t, err, "count messages in rejects mailbox")
if n != expect {
t.Fatalf("messages in rejects mailbox, found %d, expected %d", n, expect)
}
}
// Delivery from sender with bad reputation should fail.
ts.run(func(err error, client *smtpclient.Client) {
mailFrom := "remote@example.org"
@ -536,7 +547,7 @@ func TestSpam(t *testing.T) {
t.Fatalf("delivery by bad sender, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
}
checkCount("Rejects", 1)
ts.checkCount("Rejects", 1)
checkEvaluationCount(t, 0) // No positive interactions yet.
})
@ -550,8 +561,8 @@ func TestSpam(t *testing.T) {
}
tcheck(t, err, "deliver")
checkCount("mjl2junk", 1) // In ruleset rejects mailbox.
checkCount("Rejects", 1) // Same as before.
ts.checkCount("mjl2junk", 1) // In ruleset rejects mailbox.
ts.checkCount("Rejects", 1) // Same as before.
checkEvaluationCount(t, 0) // This is not an actual accept.
})
@ -571,8 +582,8 @@ func TestSpam(t *testing.T) {
tcheck(t, err, "deliver")
// Message should now be removed from Rejects mailboxes.
checkCount("Rejects", 0)
checkCount("mjl2junk", 1)
ts.checkCount("Rejects", 0)
ts.checkCount("mjl2junk", 1)
checkEvaluationCount(t, 1)
})

View file

@ -2252,8 +2252,8 @@ func OpenEmail(log mlog.Log, email string) (*Account, config.Destination, error)
if err != nil {
return nil, config.Destination{}, fmt.Errorf("%w: %v", ErrUnknownCredentials, err)
}
accountName, _, dest, err := mox.FindAccount(addr.Localpart, addr.Domain, false)
if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
accountName, _, _, dest, err := mox.LookupAddress(addr.Localpart, addr.Domain, false, false)
if err != nil && (errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
return nil, config.Destination{}, ErrUnknownCredentials
} else if err != nil {
return nil, config.Destination{}, fmt.Errorf("looking up address: %v", err)

View file

@ -8,4 +8,5 @@ Accounts:
KeepRetiredWebhookPeriod: 1h0m0s
Domain: mox.example
Destinations:
mjl2@mox.example: nil
mjl@mox.example: nil

View file

@ -1,6 +1,11 @@
Domains:
mox.example:
LocalpartCatchallSeparator: +
Aliases:
support:
Addresses:
- mjl☺@mox.example
AllowMsgFrom: true
Accounts:
mjl☺:
Domain: mox.example

View file

@ -1,5 +1,16 @@
Domains:
mox.example: nil
mox.example:
Aliases:
public:
Addresses:
- mjl@mox.example
- móx@mox.example
PostPublic: true
AllowMsgFrom: true
private:
Addresses:
- mjl@mox.example
- móx@mox.example
mox2.example: nil
Accounts:
mjl:
@ -21,3 +32,8 @@ Accounts:
TopWords: 10
IgnoreWords: 0.1
RareWords: 2
# not a member of an alias.
☺:
Domain: mox.example
Destinations:
☺@mox.example: nil

View file

@ -4,4 +4,5 @@ Accounts:
mjl:
Domain: mox.example
Destinations:
mjl2@mox.example: nil
mjl@mox.example: nil

View file

@ -255,11 +255,11 @@ var api;
// per-outgoing-message address used for sending.
OutgoingEvent["EventUnrecognized"] = "unrecognized";
})(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {}));
api.structTypes = { "Account": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true };
api.stringsTypes = { "CSRFToken": true, "OutgoingEvent": true };
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true };
api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true };
api.intsTypes = {};
api.types = {
"Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] },
"OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] },
"IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] },
"Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] },
@ -269,6 +269,10 @@ var api;
"AutomaticJunkFlags": { "Name": "AutomaticJunkFlags", "Docs": "", "Fields": [{ "Name": "Enabled", "Docs": "", "Typewords": ["bool"] }, { "Name": "JunkMailboxRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "NeutralMailboxRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "NotJunkMailboxRegexp", "Docs": "", "Typewords": ["string"] }] },
"JunkFilter": { "Name": "JunkFilter", "Docs": "", "Fields": [{ "Name": "Threshold", "Docs": "", "Typewords": ["float64"] }, { "Name": "Onegrams", "Docs": "", "Typewords": ["bool"] }, { "Name": "Twograms", "Docs": "", "Typewords": ["bool"] }, { "Name": "Threegrams", "Docs": "", "Typewords": ["bool"] }, { "Name": "MaxPower", "Docs": "", "Typewords": ["float64"] }, { "Name": "TopWords", "Docs": "", "Typewords": ["int32"] }, { "Name": "IgnoreWords", "Docs": "", "Typewords": ["float64"] }, { "Name": "RareWords", "Docs": "", "Typewords": ["int32"] }] },
"Route": { "Name": "Route", "Docs": "", "Fields": [{ "Name": "FromDomain", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ToDomain", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MinimumAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "FromDomainASCII", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ToDomainASCII", "Docs": "", "Typewords": ["[]", "string"] }] },
"AddressAlias": { "Name": "AddressAlias", "Docs": "", "Fields": [{ "Name": "SubscriptionAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Alias", "Docs": "", "Typewords": ["Alias"] }, { "Name": "MemberAddresses", "Docs": "", "Typewords": ["[]", "string"] }] },
"Alias": { "Name": "Alias", "Docs": "", "Fields": [{ "Name": "Addresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "PostPublic", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListMembers", "Docs": "", "Typewords": ["bool"] }, { "Name": "AllowMsgFrom", "Docs": "", "Typewords": ["bool"] }, { "Name": "LocalpartStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ParsedAddresses", "Docs": "", "Typewords": ["[]", "AliasAddress"] }] },
"AliasAddress": { "Name": "AliasAddress", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["Address"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destination", "Docs": "", "Typewords": ["Destination"] }] },
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Suppression": { "Name": "Suppression", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "BaseAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "OriginalAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Manual", "Docs": "", "Typewords": ["bool"] }, { "Name": "Reason", "Docs": "", "Typewords": ["string"] }] },
"ImportProgress": { "Name": "ImportProgress", "Docs": "", "Fields": [{ "Name": "Token", "Docs": "", "Typewords": ["string"] }] },
"Outgoing": { "Name": "Outgoing", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["int32"] }, { "Name": "Event", "Docs": "", "Typewords": ["OutgoingEvent"] }, { "Name": "DSN", "Docs": "", "Typewords": ["bool"] }, { "Name": "Suppressing", "Docs": "", "Typewords": ["bool"] }, { "Name": "QueueMsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "FromID", "Docs": "", "Typewords": ["string"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "WebhookQueued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SMTPCode", "Docs": "", "Typewords": ["int32"] }, { "Name": "SMTPEnhancedCode", "Docs": "", "Typewords": ["string"] }, { "Name": "Error", "Docs": "", "Typewords": ["string"] }, { "Name": "Extra", "Docs": "", "Typewords": ["{}", "string"] }] },
@ -277,6 +281,7 @@ var api;
"Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] },
"IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"Localpart": { "Name": "Localpart", "Docs": "", "Values": null },
"OutgoingEvent": { "Name": "OutgoingEvent", "Docs": "", "Values": [{ "Name": "EventDelivered", "Value": "delivered", "Docs": "" }, { "Name": "EventSuppressed", "Value": "suppressed", "Docs": "" }, { "Name": "EventDelayed", "Value": "delayed", "Docs": "" }, { "Name": "EventFailed", "Value": "failed", "Docs": "" }, { "Name": "EventRelayed", "Value": "relayed", "Docs": "" }, { "Name": "EventExpanded", "Value": "expanded", "Docs": "" }, { "Name": "EventCanceled", "Value": "canceled", "Docs": "" }, { "Name": "EventUnrecognized", "Value": "unrecognized", "Docs": "" }] },
};
api.parser = {
@ -290,6 +295,10 @@ var api;
AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v),
JunkFilter: (v) => api.parse("JunkFilter", v),
Route: (v) => api.parse("Route", v),
AddressAlias: (v) => api.parse("AddressAlias", v),
Alias: (v) => api.parse("Alias", v),
AliasAddress: (v) => api.parse("AliasAddress", v),
Address: (v) => api.parse("Address", v),
Suppression: (v) => api.parse("Suppression", v),
ImportProgress: (v) => api.parse("ImportProgress", v),
Outgoing: (v) => api.parse("Outgoing", v),
@ -298,6 +307,7 @@ var api;
Structure: (v) => api.parse("Structure", v),
IncomingMeta: (v) => api.parse("IncomingMeta", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
Localpart: (v) => api.parse("Localpart", v),
OutgoingEvent: (v) => api.parse("OutgoingEvent", v),
};
// Account exports web API functions for the account web interface. All its
@ -1371,7 +1381,10 @@ const index = async () => {
await check(fullNameFieldset, client.AccountSaveFullName(fullName.value));
fullName.setAttribute('value', fullName.value);
fullNameForm.reset();
}), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() {
}), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list.')), dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th())), (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), dom.td(a.SubscriptionAddress), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td((a.MemberAddresses || []).length === 0 ? [] :
dom.clickbutton('Show members', function click() {
popup(dom.h1('Members of alias ', a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), dom.ul((a.MemberAddresses || []).map(addr => dom.li(addr))));
}))))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() {
passwordHint.style.display = '';
})), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) {
e.preventDefault();

View file

@ -765,6 +765,40 @@ const index = async () => {
),
dom.br(),
dom.h2('Aliases/lists'),
dom.table(
dom.thead(
dom.tr(
dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list.')),
dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')),
dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')),
dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')),
dom.th(),
),
),
(acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [],
(acc.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a =>
dom.tr(
dom.td(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)),
dom.td(a.SubscriptionAddress),
dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'),
dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'),
dom.td(
(a.MemberAddresses || []).length === 0 ? [] :
dom.clickbutton('Show members', function click() {
popup(
dom.h1('Members of alias ', a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)),
dom.ul(
(a.MemberAddresses || []).map(addr => dom.li(addr)),
),
)
}),
),
),
),
),
dom.br(),
dom.h2('Change password'),
passwordForm=dom.form(
passwordFieldset=dom.fieldset(

View file

@ -26,6 +26,8 @@ import (
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/queue"
@ -227,7 +229,20 @@ func TestAccount(t *testing.T) {
err = queue.Init() // For DB.
tcheck(t, err, "queue init")
account, _, _, _ := api.Account(ctx)
// Check we don't see the alias member list.
tcompare(t, len(account.Aliases), 1)
tcompare(t, account.Aliases[0], config.AddressAlias{
SubscriptionAddress: "mjl☺@mox.example",
Alias: config.Alias{
LocalpartStr: "support",
Domain: dns.Domain{ASCII: "mox.example"},
AllowMsgFrom: true,
},
})
api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed

View file

@ -589,6 +589,14 @@
"Typewords": [
"Domain"
]
},
{
"Name": "Aliases",
"Docs": "",
"Typewords": [
"[]",
"AddressAlias"
]
}
]
},
@ -933,6 +941,138 @@
}
]
},
{
"Name": "AddressAlias",
"Docs": "",
"Fields": [
{
"Name": "SubscriptionAddress",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Alias",
"Docs": "Without members.",
"Typewords": [
"Alias"
]
},
{
"Name": "MemberAddresses",
"Docs": "Only if allowed to see.",
"Typewords": [
"[]",
"string"
]
}
]
},
{
"Name": "Alias",
"Docs": "",
"Fields": [
{
"Name": "Addresses",
"Docs": "",
"Typewords": [
"[]",
"string"
]
},
{
"Name": "PostPublic",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "ListMembers",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "AllowMsgFrom",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "LocalpartStr",
"Docs": "In encoded form.",
"Typewords": [
"string"
]
},
{
"Name": "Domain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "ParsedAddresses",
"Docs": "Matches addresses.",
"Typewords": [
"[]",
"AliasAddress"
]
}
]
},
{
"Name": "AliasAddress",
"Docs": "",
"Fields": [
{
"Name": "Address",
"Docs": "Parsed address.",
"Typewords": [
"Address"
]
},
{
"Name": "AccountName",
"Docs": "Looked up.",
"Typewords": [
"string"
]
},
{
"Name": "Destination",
"Docs": "Belonging to address.",
"Typewords": [
"Destination"
]
}
]
},
{
"Name": "Address",
"Docs": "Address is a parsed email address.",
"Fields": [
{
"Name": "Localpart",
"Docs": "",
"Typewords": [
"Localpart"
]
},
{
"Name": "Domain",
"Docs": "todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.",
"Typewords": [
"Domain"
]
}
]
},
{
"Name": "Suppression",
"Docs": "Suppression is an address to which messages will not be delivered. Attempts to\ndeliver or queue will result in an immediate permanent failure to deliver.",
@ -1365,6 +1505,11 @@
"Docs": "",
"Values": null
},
{
"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.\nLocalparts are in Unicode NFC.",
"Values": null
},
{
"Name": "OutgoingEvent",
"Docs": "OutgoingEvent is an activity for an outgoing delivery. Either generated by the\nqueue, or through an incoming DSN (delivery status notification) message.",

View file

@ -23,6 +23,7 @@ export interface Account {
NoFirstTimeSenderDelay: boolean
Routes?: Route[] | null
DNSDomain: Domain // Parsed form of Domain.
Aliases?: AddressAlias[] | null
}
export interface OutgoingWebhook {
@ -96,6 +97,34 @@ export interface Route {
ToDomainASCII?: string[] | null
}
export interface AddressAlias {
SubscriptionAddress: string
Alias: Alias // Without members.
MemberAddresses?: string[] | null // Only if allowed to see.
}
export interface Alias {
Addresses?: string[] | null
PostPublic: boolean
ListMembers: boolean
AllowMsgFrom: boolean
LocalpartStr: string // In encoded form.
Domain: Domain
ParsedAddresses?: AliasAddress[] | null // Matches addresses.
}
export interface AliasAddress {
Address: Address // Parsed address.
AccountName: string // Looked up.
Destination: Destination // Belonging to address.
}
// Address is a parsed email address.
export interface Address {
Localpart: Localpart
Domain: Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
}
// Suppression is an address to which messages will not be delivered. Attempts to
// deliver or queue will result in an immediate permanent failure to deliver.
export interface Suppression {
@ -177,6 +206,12 @@ export interface IncomingMeta {
export type CSRFToken = string
// Localpart is a decoded local part of an email address, before the "@".
// For quoted strings, values do not hold the double quote or escaping backslashes.
// An empty string can be a valid localpart.
// Localparts are in Unicode NFC.
export type Localpart = string
// OutgoingEvent is an activity for an outgoing delivery. Either generated by the
// queue, or through an incoming DSN (delivery status notification) message.
export enum OutgoingEvent {
@ -203,11 +238,11 @@ export enum OutgoingEvent {
EventUnrecognized = "unrecognized",
}
export const structTypes: {[typename: string]: boolean} = {"Account":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true}
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"OutgoingEvent":true}
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true}
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true}
export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = {
"Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]},
"Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]},
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]},
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]},
"Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]},
@ -217,6 +252,10 @@ export const types: TypenameMap = {
"AutomaticJunkFlags": {"Name":"AutomaticJunkFlags","Docs":"","Fields":[{"Name":"Enabled","Docs":"","Typewords":["bool"]},{"Name":"JunkMailboxRegexp","Docs":"","Typewords":["string"]},{"Name":"NeutralMailboxRegexp","Docs":"","Typewords":["string"]},{"Name":"NotJunkMailboxRegexp","Docs":"","Typewords":["string"]}]},
"JunkFilter": {"Name":"JunkFilter","Docs":"","Fields":[{"Name":"Threshold","Docs":"","Typewords":["float64"]},{"Name":"Onegrams","Docs":"","Typewords":["bool"]},{"Name":"Twograms","Docs":"","Typewords":["bool"]},{"Name":"Threegrams","Docs":"","Typewords":["bool"]},{"Name":"MaxPower","Docs":"","Typewords":["float64"]},{"Name":"TopWords","Docs":"","Typewords":["int32"]},{"Name":"IgnoreWords","Docs":"","Typewords":["float64"]},{"Name":"RareWords","Docs":"","Typewords":["int32"]}]},
"Route": {"Name":"Route","Docs":"","Fields":[{"Name":"FromDomain","Docs":"","Typewords":["[]","string"]},{"Name":"ToDomain","Docs":"","Typewords":["[]","string"]},{"Name":"MinimumAttempts","Docs":"","Typewords":["int32"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"FromDomainASCII","Docs":"","Typewords":["[]","string"]},{"Name":"ToDomainASCII","Docs":"","Typewords":["[]","string"]}]},
"AddressAlias": {"Name":"AddressAlias","Docs":"","Fields":[{"Name":"SubscriptionAddress","Docs":"","Typewords":["string"]},{"Name":"Alias","Docs":"","Typewords":["Alias"]},{"Name":"MemberAddresses","Docs":"","Typewords":["[]","string"]}]},
"Alias": {"Name":"Alias","Docs":"","Fields":[{"Name":"Addresses","Docs":"","Typewords":["[]","string"]},{"Name":"PostPublic","Docs":"","Typewords":["bool"]},{"Name":"ListMembers","Docs":"","Typewords":["bool"]},{"Name":"AllowMsgFrom","Docs":"","Typewords":["bool"]},{"Name":"LocalpartStr","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]},{"Name":"ParsedAddresses","Docs":"","Typewords":["[]","AliasAddress"]}]},
"AliasAddress": {"Name":"AliasAddress","Docs":"","Fields":[{"Name":"Address","Docs":"","Typewords":["Address"]},{"Name":"AccountName","Docs":"","Typewords":["string"]},{"Name":"Destination","Docs":"","Typewords":["Destination"]}]},
"Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Localpart","Docs":"","Typewords":["Localpart"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"Suppression": {"Name":"Suppression","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"BaseAddress","Docs":"","Typewords":["string"]},{"Name":"OriginalAddress","Docs":"","Typewords":["string"]},{"Name":"Manual","Docs":"","Typewords":["bool"]},{"Name":"Reason","Docs":"","Typewords":["string"]}]},
"ImportProgress": {"Name":"ImportProgress","Docs":"","Fields":[{"Name":"Token","Docs":"","Typewords":["string"]}]},
"Outgoing": {"Name":"Outgoing","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["int32"]},{"Name":"Event","Docs":"","Typewords":["OutgoingEvent"]},{"Name":"DSN","Docs":"","Typewords":["bool"]},{"Name":"Suppressing","Docs":"","Typewords":["bool"]},{"Name":"QueueMsgID","Docs":"","Typewords":["int64"]},{"Name":"FromID","Docs":"","Typewords":["string"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"WebhookQueued","Docs":"","Typewords":["timestamp"]},{"Name":"SMTPCode","Docs":"","Typewords":["int32"]},{"Name":"SMTPEnhancedCode","Docs":"","Typewords":["string"]},{"Name":"Error","Docs":"","Typewords":["string"]},{"Name":"Extra","Docs":"","Typewords":["{}","string"]}]},
@ -225,6 +264,7 @@ export const types: TypenameMap = {
"Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]},
"IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]},
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
"Localpart": {"Name":"Localpart","Docs":"","Values":null},
"OutgoingEvent": {"Name":"OutgoingEvent","Docs":"","Values":[{"Name":"EventDelivered","Value":"delivered","Docs":""},{"Name":"EventSuppressed","Value":"suppressed","Docs":""},{"Name":"EventDelayed","Value":"delayed","Docs":""},{"Name":"EventFailed","Value":"failed","Docs":""},{"Name":"EventRelayed","Value":"relayed","Docs":""},{"Name":"EventExpanded","Value":"expanded","Docs":""},{"Name":"EventCanceled","Value":"canceled","Docs":""},{"Name":"EventUnrecognized","Value":"unrecognized","Docs":""}]},
}
@ -239,6 +279,10 @@ export const parser = {
AutomaticJunkFlags: (v: any) => parse("AutomaticJunkFlags", v) as AutomaticJunkFlags,
JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter,
Route: (v: any) => parse("Route", v) as Route,
AddressAlias: (v: any) => parse("AddressAlias", v) as AddressAlias,
Alias: (v: any) => parse("Alias", v) as Alias,
AliasAddress: (v: any) => parse("AliasAddress", v) as AliasAddress,
Address: (v: any) => parse("Address", v) as Address,
Suppression: (v: any) => parse("Suppression", v) as Suppression,
ImportProgress: (v: any) => parse("ImportProgress", v) as ImportProgress,
Outgoing: (v: any) => parse("Outgoing", v) as Outgoing,
@ -247,6 +291,7 @@ export const parser = {
Structure: (v: any) => parse("Structure", v) as Structure,
IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
Localpart: (v: any) => parse("Localpart", v) as Localpart,
OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent,
}

View file

@ -1535,7 +1535,7 @@ func (Admin) DomainConfig(ctx context.Context, domain string) config.Domain {
}
// DomainLocalparts returns the encoded localparts and accounts configured in domain.
func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string) {
func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string, localpartAliases map[string]config.Alias) {
d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain")
_, ok := mox.Conf.Domain(d)
@ -2430,8 +2430,9 @@ func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes [
// DomainRoutesSave saves routes for a domain.
func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Routes = routes
return nil
})
xcheckf(ctx, err, "saving domain routes")
}
@ -2446,16 +2447,18 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
// DomainDescriptionSave saves the description for a domain.
func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Description = descr
return nil
})
xcheckf(ctx, err, "saving domain description")
}
// DomainClientSettingsDomainSave saves the client settings domain for a domain.
func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.ClientSettingsDomain = clientSettingsDomain
return nil
})
xcheckf(ctx, err, "saving client settings domain")
}
@ -2463,9 +2466,10 @@ func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, cli
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain.
func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.LocalpartCatchallSeparator = localpartCatchallSeparator
domain.LocalpartCaseSensitive = localpartCaseSensitive
return nil
})
xcheckf(ctx, err, "saving localpart settings for domain")
}
@ -2474,7 +2478,7 @@ func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpar
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" {
d.DMARC = nil
} else {
@ -2485,6 +2489,7 @@ func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart,
Mailbox: mailbox,
}
}
return nil
})
xcheckf(ctx, err, "saving dmarc reporting address/settings for domain")
}
@ -2493,7 +2498,7 @@ func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart,
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" {
d.TLSRPT = nil
} else {
@ -2504,6 +2509,7 @@ func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart,
Mailbox: mailbox,
}
}
return nil
})
xcheckf(ctx, err, "saving tls reporting address/settings for domain")
}
@ -2511,7 +2517,7 @@ func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart,
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served.
func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
if policyID == "" {
d.MTASTS = nil
} else {
@ -2522,6 +2528,7 @@ func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string,
MX: mx,
}
}
return nil
})
xcheckf(ctx, err, "saving mtasts policy for domain")
}
@ -2557,7 +2564,7 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma
}
}
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
if len(selectors) != len(d.DKIM.Selectors) {
xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors")
}
@ -2591,6 +2598,50 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma
Selectors: sels,
Sign: sign,
}
return nil
})
xcheckf(ctx, err, "saving dkim selector for domain")
}
func xparseAddress(ctx context.Context, lp, domain string) smtp.Address {
xlp, err := smtp.ParseLocalpart(lp)
xcheckuserf(ctx, err, "parsing localpart")
d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain")
return smtp.NewAddress(xlp, d)
}
func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAdd(ctx, addr, alias)
xcheckf(ctx, err, "adding alias")
}
func (Admin) AliasUpdate(ctx context.Context, aliaslp string, domainName string, postPublic, listMembers, allowMsgFrom bool) {
addr := xparseAddress(ctx, aliaslp, domainName)
alias := config.Alias{
PostPublic: postPublic,
ListMembers: listMembers,
AllowMsgFrom: allowMsgFrom,
}
err := mox.AliasUpdate(ctx, addr, alias)
xcheckf(ctx, err, "saving alias")
}
func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasRemove(ctx, addr)
xcheckf(ctx, err, "removing alias")
}
func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesAdd(ctx, addr, addresses)
xcheckf(ctx, err, "adding address to alias")
}
func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesRemove(ctx, addr, addresses)
xcheckf(ctx, err, "removing address from alias")
}

View file

@ -337,7 +337,7 @@ var api;
SPFResult["SPFTemperror"] = "temperror";
SPFResult["SPFPermerror"] = "permerror";
})(SPFResult = api.SPFResult || (api.SPFResult = {}));
api.structTypes = { "Account": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
api.stringsTypes = { "Align": true, "Alignment": true, "CSRFToken": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true };
api.intsTypes = {};
api.types = {
@ -372,7 +372,7 @@ var api;
"AutoconfCheckResult": { "Name": "AutoconfCheckResult", "Docs": "", "Fields": [{ "Name": "ClientSettingsDomainIPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] },
"AutodiscoverCheckResult": { "Name": "AutodiscoverCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "AutodiscoverSRV"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] },
"AutodiscoverSRV": { "Name": "AutodiscoverSRV", "Docs": "", "Fields": [{ "Name": "Target", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Priority", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Weight", "Docs": "", "Typewords": ["uint16"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }] },
"ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }] },
"ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["{}", "Alias"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"DKIM": { "Name": "DKIM", "Docs": "", "Fields": [{ "Name": "Selectors", "Docs": "", "Typewords": ["{}", "Selector"] }, { "Name": "Sign", "Docs": "", "Typewords": ["[]", "string"] }] },
"Selector": { "Name": "Selector", "Docs": "", "Fields": [{ "Name": "Hash", "Docs": "", "Typewords": ["string"] }, { "Name": "HashEffective", "Docs": "", "Typewords": ["string"] }, { "Name": "Canonicalization", "Docs": "", "Typewords": ["Canonicalization"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HeadersEffective", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "DontSealHeaders", "Docs": "", "Typewords": ["bool"] }, { "Name": "Expiration", "Docs": "", "Typewords": ["string"] }, { "Name": "PrivateKeyFile", "Docs": "", "Typewords": ["string"] }, { "Name": "Algorithm", "Docs": "", "Typewords": ["string"] }] },
"Canonicalization": { "Name": "Canonicalization", "Docs": "", "Fields": [{ "Name": "HeaderRelaxed", "Docs": "", "Typewords": ["bool"] }, { "Name": "BodyRelaxed", "Docs": "", "Typewords": ["bool"] }] },
@ -380,14 +380,18 @@ var api;
"MTASTS": { "Name": "MTASTS", "Docs": "", "Fields": [{ "Name": "PolicyID", "Docs": "", "Typewords": ["string"] }, { "Name": "Mode", "Docs": "", "Typewords": ["Mode"] }, { "Name": "MaxAge", "Docs": "", "Typewords": ["int64"] }, { "Name": "MX", "Docs": "", "Typewords": ["[]", "string"] }] },
"TLSRPT": { "Name": "TLSRPT", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "ParsedLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"Route": { "Name": "Route", "Docs": "", "Fields": [{ "Name": "FromDomain", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ToDomain", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MinimumAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "FromDomainASCII", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ToDomainASCII", "Docs": "", "Typewords": ["[]", "string"] }] },
"Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] },
"IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] },
"Alias": { "Name": "Alias", "Docs": "", "Fields": [{ "Name": "Addresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "PostPublic", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListMembers", "Docs": "", "Typewords": ["bool"] }, { "Name": "AllowMsgFrom", "Docs": "", "Typewords": ["bool"] }, { "Name": "LocalpartStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ParsedAddresses", "Docs": "", "Typewords": ["[]", "AliasAddress"] }] },
"AliasAddress": { "Name": "AliasAddress", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["Address"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destination", "Docs": "", "Typewords": ["Destination"] }] },
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] },
"Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] },
"OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] },
"IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] },
"SubjectPass": { "Name": "SubjectPass", "Docs": "", "Fields": [{ "Name": "Period", "Docs": "", "Typewords": ["int64"] }] },
"AutomaticJunkFlags": { "Name": "AutomaticJunkFlags", "Docs": "", "Fields": [{ "Name": "Enabled", "Docs": "", "Typewords": ["bool"] }, { "Name": "JunkMailboxRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "NeutralMailboxRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "NotJunkMailboxRegexp", "Docs": "", "Typewords": ["string"] }] },
"JunkFilter": { "Name": "JunkFilter", "Docs": "", "Fields": [{ "Name": "Threshold", "Docs": "", "Typewords": ["float64"] }, { "Name": "Onegrams", "Docs": "", "Typewords": ["bool"] }, { "Name": "Twograms", "Docs": "", "Typewords": ["bool"] }, { "Name": "Threegrams", "Docs": "", "Typewords": ["bool"] }, { "Name": "MaxPower", "Docs": "", "Typewords": ["float64"] }, { "Name": "TopWords", "Docs": "", "Typewords": ["int32"] }, { "Name": "IgnoreWords", "Docs": "", "Typewords": ["float64"] }, { "Name": "RareWords", "Docs": "", "Typewords": ["int32"] }] },
"AddressAlias": { "Name": "AddressAlias", "Docs": "", "Fields": [{ "Name": "SubscriptionAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Alias", "Docs": "", "Typewords": ["Alias"] }, { "Name": "MemberAddresses", "Docs": "", "Typewords": ["[]", "string"] }] },
"PolicyRecord": { "Name": "PolicyRecord", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ValidEnd", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastUpdate", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastUse", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Backoff", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecordID", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Mode", "Docs": "", "Typewords": ["Mode"] }, { "Name": "MX", "Docs": "", "Typewords": ["[]", "STSMX"] }, { "Name": "MaxAgeSeconds", "Docs": "", "Typewords": ["int32"] }, { "Name": "Extensions", "Docs": "", "Typewords": ["[]", "Pair"] }, { "Name": "PolicyText", "Docs": "", "Typewords": ["string"] }] },
"TLSReportRecord": { "Name": "TLSReportRecord", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "FromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "HostReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Report", "Docs": "", "Typewords": ["Report"] }] },
"Report": { "Name": "Report", "Docs": "", "Fields": [{ "Name": "OrganizationName", "Docs": "", "Typewords": ["string"] }, { "Name": "DateRange", "Docs": "", "Typewords": ["TLSRPTDateRange"] }, { "Name": "ContactInfo", "Docs": "", "Typewords": ["string"] }, { "Name": "ReportID", "Docs": "", "Typewords": ["string"] }, { "Name": "Policies", "Docs": "", "Typewords": ["[]", "Result"] }] },
@ -502,14 +506,18 @@ var api;
MTASTS: (v) => api.parse("MTASTS", v),
TLSRPT: (v) => api.parse("TLSRPT", v),
Route: (v) => api.parse("Route", v),
Alias: (v) => api.parse("Alias", v),
AliasAddress: (v) => api.parse("AliasAddress", v),
Address: (v) => api.parse("Address", v),
Destination: (v) => api.parse("Destination", v),
Ruleset: (v) => api.parse("Ruleset", v),
Account: (v) => api.parse("Account", v),
OutgoingWebhook: (v) => api.parse("OutgoingWebhook", v),
IncomingWebhook: (v) => api.parse("IncomingWebhook", v),
Destination: (v) => api.parse("Destination", v),
Ruleset: (v) => api.parse("Ruleset", v),
SubjectPass: (v) => api.parse("SubjectPass", v),
AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v),
JunkFilter: (v) => api.parse("JunkFilter", v),
AddressAlias: (v) => api.parse("AddressAlias", v),
PolicyRecord: (v) => api.parse("PolicyRecord", v),
TLSReportRecord: (v) => api.parse("TLSReportRecord", v),
Report: (v) => api.parse("Report", v),
@ -680,7 +688,7 @@ var api;
async DomainLocalparts(domain) {
const fn = "DomainLocalparts";
const paramTypes = [["string"]];
const returnTypes = [["{}", "string"]];
const returnTypes = [["{}", "string"], ["{}", "Alias"]];
const params = [domain];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
@ -1351,6 +1359,41 @@ var api;
const params = [domainName, selectors, sign];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AliasAdd(aliaslp, domainName, alias) {
const fn = "AliasAdd";
const paramTypes = [["string"], ["string"], ["Alias"]];
const returnTypes = [];
const params = [aliaslp, domainName, alias];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AliasUpdate(aliaslp, domainName, postPublic, listMembers, allowMsgFrom) {
const fn = "AliasUpdate";
const paramTypes = [["string"], ["string"], ["bool"], ["bool"], ["bool"]];
const returnTypes = [];
const params = [aliaslp, domainName, postPublic, listMembers, allowMsgFrom];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AliasRemove(aliaslp, domainName) {
const fn = "AliasRemove";
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [aliaslp, domainName];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AliasAddressesAdd(aliaslp, domainName, addresses) {
const fn = "AliasAddressesAdd";
const paramTypes = [["string"], ["string"], ["[]", "string"]];
const returnTypes = [];
const params = [aliaslp, domainName, addresses];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AliasAddressesRemove(aliaslp, domainName, addresses) {
const fn = "AliasAddressesRemove";
const paramTypes = [["string"], ["string"], ["[]", "string"]];
const returnTypes = [];
const params = [aliaslp, domainName, addresses];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
api.defaultBaseURL = (function () {
@ -2221,7 +2264,10 @@ const account = async (name) => {
await check(fieldset, client.AddressAdd(address, name));
form.reset();
window.location.reload(); // todo: only reload the destinations
}, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.submitbutton('Save')), async function submit(e) {
}, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address'), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(a.SubscriptionAddress), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) {
await check(e.target, client.AliasAddressesRemove(a.Alias.LocalpartStr, domainName(a.Alias.Domain), [a.SubscriptionAddress]));
window.location.reload(); // todo: reload less
}))))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.submitbutton('Save')), async function submit(e) {
e.stopPropagation();
e.preventDefault();
await check(fieldsetSettings, client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked));
@ -2312,7 +2358,7 @@ const formatDuration = (v, goDuration) => {
const domain = async (d) => {
const end = new Date();
const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000);
const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([
const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([
client.DMARCSummaries(start, end, d),
client.TLSRPTSummaries(start, end, d),
client.DomainLocalparts(d),
@ -2326,6 +2372,9 @@ const domain = async (d) => {
let addrFieldset;
let addrLocalpart;
let addrAccount;
let aliasFieldset;
let aliasLocalpart;
let aliasAddresses;
let descrFieldset;
let descrText;
let clientSettingsDomainFieldset;
@ -2401,7 +2450,23 @@ const domain = async (d) => {
await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value));
addrForm.reset();
window.location.reload(); // todo: only reload the addresses
}, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(a)))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) {
}, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(a)))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [], Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => {
return dom.tr(dom.td(dom.a(a.LocalpartStr, attr.href('#domains/' + d + '/alias/' + encodeURIComponent(a.LocalpartStr)))), dom.td(a.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.ListMembers ? 'Yes' : 'No'));
})), dom.br(), dom.h2('Add alias'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
const alias = {
Addresses: aliasAddresses.value.split('\n').map(s => s.trim()).filter(s => !!s),
PostPublic: false,
ListMembers: false,
AllowMsgFrom: false,
// Ignored:
LocalpartStr: '',
Domain: dnsdomain,
};
await check(aliasFieldset, client.AliasAdd(aliasLocalpart.value, d, alias));
window.location.hash = '#domains/' + d + '/alias/' + aliasLocalpart.value;
}, aliasFieldset = dom.fieldset(style({ display: 'flex', alignItems: 'flex-start', gap: '1em' }), dom.label(dom.div('Localpart', attr.title('The localpart is the part before the "@"-sign of an address.')), aliasLocalpart = dom.input(attr.required('')), '@', domainName(dnsdomain), ' '), dom.label(dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')), aliasAddresses = dom.textarea(attr.required(''), attr.rows('1'), function focus() { aliasAddresses.setAttribute('rows', '5'); })), dom.div(dom.div('\u00a0'), dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.'))))), dom.br(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value));
@ -2571,6 +2636,44 @@ const domain = async (d) => {
window.location.hash = '#';
}));
};
const domainAlias = async (d, aliasLocalpart) => {
const domain = await client.DomainConfig(d);
const alias = (domain.Aliases || {})[aliasLocalpart];
if (!alias) {
throw new Error('alias not found');
}
let aliasFieldset;
let postPublic;
let listMembers;
let allowMsgFrom;
let addFieldset;
let addAddress;
let delFieldset;
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/' + d), 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain)), dom.h2('Alias'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked));
}, aliasFieldset = dom.fieldset(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.label(postPublic = dom.input(attr.type('checkbox'), alias.PostPublic ? attr.checked('') : []), ' Public, anyone can post instead of only members'), dom.label(listMembers = dom.input(attr.type('checkbox'), alias.ListMembers ? attr.checked('') : []), ' Members can list other members'), dom.label(allowMsgFrom = dom.input(attr.type('checkbox'), alias.AllowMsgFrom ? attr.checked('') : []), ' Allow messages to use the alias address in the message From header'), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Save')))), dom.br(), dom.h2('Members'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th())), dom.tbody((alias.Addresses || []).map((address, index) => {
const pa = (alias.ParsedAddresses || [])[index];
return dom.tr(dom.td(address), dom.td(dom.a(pa.AccountName, attr.href('#accounts/' + pa.AccountName))), dom.td(dom.clickbutton('Remove', async function click(e) {
await check(e.target, client.AliasAddressesRemove(aliasLocalpart, d, [address]));
window.location.reload(); // todo: reload less
})));
})), dom.tfoot(dom.tr(dom.td(attr.colspan('3'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
await check(addFieldset, client.AliasAddressesAdd(aliasLocalpart, d, addAddress.value.split('\n').map(s => s.trim()).filter(s => s)));
window.location.reload(); // todo: reload less
}, addFieldset = dom.fieldset(addAddress = dom.textarea(attr.required(''), attr.rows('1'), attr.placeholder('localpart@domain'), function focus() { addAddress.setAttribute('rows', '5'); }), ' ', dom.submitbutton('Add', style({ verticalAlign: 'top' })))))))), dom.br(), dom.h2('Danger'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
if (!confirm('Are you sure you want to remove this alias?')) {
return;
}
await check(delFieldset, client.AliasRemove(aliasLocalpart, d));
window.location.hash = '#domains/' + d;
}, delFieldset = dom.fieldset(dom.div(dom.submitbutton('Remove alias')))));
};
const domainDNSRecords = async (d) => {
const [records, dnsdomain] = await Promise.all([
client.DomainRecords(d),
@ -4011,6 +4114,9 @@ const init = async () => {
else if (t[0] === 'domains' && t.length === 2) {
await domain(t[1]);
}
else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') {
await domainAlias(t[1], t[3]);
}
else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') {
await domainDMARC(t[1]);
}

View file

@ -875,6 +875,37 @@ const account = async (name: string) => {
),
),
dom.br(),
dom.h2('Aliases/lists'),
dom.table(
dom.thead(
dom.tr(
dom.th('Alias address'),
dom.th('Subscription address'),
dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')),
dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')),
dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')),
),
),
(config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [],
(config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a =>
dom.tr(
dom.td(dom.a(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain), attr.href('#domains/'+domainName(a.Alias.Domain)+'/alias/'+encodeURIComponent(a.Alias.LocalpartStr)))),
dom.td(a.SubscriptionAddress),
dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'),
dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'),
dom.td(a.Alias.ListMembers ? 'Yes' : 'No'),
dom.td(
dom.clickbutton('Remove', async function click(e: MouseEvent) {
await check(e.target! as HTMLButtonElement, client.AliasAddressesRemove(a.Alias.LocalpartStr, domainName(a.Alias.Domain), [a.SubscriptionAddress]))
window.location.reload() // todo: reload less
}),
),
),
),
),
dom.br(),
dom.h2('Settings'),
dom.form(
fieldsetSettings=dom.fieldset(
@ -1009,7 +1040,7 @@ const formatDuration = (v: number, goDuration?: boolean) => {
const domain = async (d: string) => {
const end = new Date()
const start = new Date(new Date().getTime() - 30*24*3600*1000)
const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([
const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([
client.DMARCSummaries(start, end, d),
client.TLSRPTSummaries(start, end, d),
client.DomainLocalparts(d),
@ -1025,6 +1056,10 @@ const domain = async (d: string) => {
let addrLocalpart: HTMLInputElement
let addrAccount: HTMLSelectElement
let aliasFieldset: HTMLFieldSetElement
let aliasLocalpart: HTMLInputElement
let aliasAddresses: HTMLTextAreaElement
let descrFieldset: HTMLFieldSetElement
let descrText: HTMLInputElement
@ -1247,7 +1282,6 @@ const domain = async (d: string) => {
),
),
dom.br(),
dom.h2('Add address'),
addrForm=dom.form(
async function submit(e: SubmitEvent) {
@ -1278,6 +1312,64 @@ const domain = async (d: string) => {
),
dom.br(),
dom.h2('Aliases/lists'),
dom.table(
dom.thead(
dom.tr(
dom.th('Address'),
dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')),
dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')),
dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')),
),
),
Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [],
Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => {
return dom.tr(
dom.td(dom.a(a.LocalpartStr, attr.href('#domains/'+d+'/alias/'+encodeURIComponent(a.LocalpartStr)))),
dom.td(a.PostPublic ? 'Anyone' : 'Members only'),
dom.td(a.AllowMsgFrom ? 'Yes' : 'No'),
dom.td(a.ListMembers ? 'Yes' : 'No'),
)
}),
),
dom.br(),
dom.h2('Add alias'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
const alias: api.Alias = {
Addresses: aliasAddresses.value.split('\n').map(s => s.trim()).filter(s => !!s),
PostPublic: false,
ListMembers: false,
AllowMsgFrom: false,
// Ignored:
LocalpartStr: '',
Domain: dnsdomain,
}
await check(aliasFieldset, client.AliasAdd(aliasLocalpart.value, d, alias))
window.location.hash = '#domains/'+d+'/alias/'+aliasLocalpart.value
},
aliasFieldset=dom.fieldset(
style({display: 'flex', alignItems: 'flex-start', gap: '1em'}),
dom.label(
dom.div('Localpart', attr.title('The localpart is the part before the "@"-sign of an address.')),
aliasLocalpart=dom.input(attr.required('')),
'@', domainName(dnsdomain),
' ',
),
dom.label(
dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')),
aliasAddresses=dom.textarea(attr.required(''), attr.rows('1'), function focus() { aliasAddresses.setAttribute('rows', '5') }),
),
dom.div(
dom.div('\u00a0'),
dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.')),
),
),
),
dom.br(),
RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes: api.Route[]) => await client.DomainRoutesSave(d, routes)),
dom.br(),
@ -1680,6 +1772,122 @@ const domain = async (d: string) => {
)
}
const domainAlias = async (d: string, aliasLocalpart: string) => {
const domain = await client.DomainConfig(d)
const alias = (domain.Aliases || {})[aliasLocalpart]
if (!alias) {
throw new Error('alias not found')
}
let aliasFieldset: HTMLFieldSetElement
let postPublic: HTMLInputElement
let listMembers: HTMLInputElement
let allowMsgFrom: HTMLInputElement
let addFieldset: HTMLFieldSetElement
let addAddress: HTMLTextAreaElement
let delFieldset: HTMLFieldSetElement
dom._kids(page,
crumbs(
crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d),
'Alias '+aliasLocalpart+'@'+domainName(domain.Domain),
),
dom.h2('Alias'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked))
},
aliasFieldset=dom.fieldset(
style({display: 'flex', flexDirection: 'column', gap: '.5ex'}),
dom.label(
postPublic=dom.input(attr.type('checkbox'), alias.PostPublic ? attr.checked('') : []),
' Public, anyone can post instead of only members',
),
dom.label(
listMembers=dom.input(attr.type('checkbox'), alias.ListMembers ? attr.checked('') : []),
' Members can list other members',
),
dom.label(
allowMsgFrom=dom.input(attr.type('checkbox'), alias.AllowMsgFrom ? attr.checked('') : []),
' Allow messages to use the alias address in the message From header',
),
dom.div(style({marginTop: '1ex'}), dom.submitbutton('Save')),
),
),
dom.br(),
dom.h2('Members'),
dom.table(
dom.thead(
dom.tr(
dom.th('Address'),
dom.th('Account'),
dom.th(),
),
),
dom.tbody(
(alias.Addresses || []).map((address, index) => {
const pa = (alias.ParsedAddresses || [])[index]
return dom.tr(
dom.td(address),
dom.td(dom.a(pa.AccountName, attr.href('#accounts/'+pa.AccountName))),
dom.td(
dom.clickbutton('Remove', async function click(e: MouseEvent) {
await check(e.target! as HTMLButtonElement, client.AliasAddressesRemove(aliasLocalpart, d, [address]))
window.location.reload() // todo: reload less
}),
),
)
}),
),
dom.tfoot(
dom.tr(
dom.td(
attr.colspan('3'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
await check(addFieldset, client.AliasAddressesAdd(aliasLocalpart, d, addAddress.value.split('\n').map(s => s.trim()).filter(s => s)))
window.location.reload() // todo: reload less
},
addFieldset=dom.fieldset(
addAddress=dom.textarea(attr.required(''), attr.rows('1'), attr.placeholder('localpart@domain'), function focus() { addAddress.setAttribute('rows', '5') }), ' ',
dom.submitbutton('Add', style({verticalAlign: 'top'})),
),
),
),
),
),
),
dom.br(),
dom.h2('Danger'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
if (!confirm('Are you sure you want to remove this alias?')) {
return
}
await check(delFieldset, client.AliasRemove(aliasLocalpart, d))
window.location.hash = '#domains/'+d
},
delFieldset=dom.fieldset(
dom.div(dom.submitbutton('Remove alias')),
),
),
)
}
const domainDNSRecords = async (d: string) => {
const [records, dnsdomain] = await Promise.all([
client.DomainRecords(d),
@ -4846,6 +5054,8 @@ const init = async () => {
await account(t[1])
} else if (t[0] === 'domains' && t.length === 2) {
await domain(t[1])
} else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') {
await domainAlias(t[1], t[3])
} else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') {
await domainDMARC(t[1])
} else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) {

View file

@ -321,6 +321,44 @@ func TestAdmin(t *testing.T) {
api.DomainDKIMRemove(ctxbg, "mox.example", "testsel")
tneedErrorCode(t, "user:error", func() { api.DomainDKIMRemove(ctxbg, "mox.example", "testsel") }) // Already removed.
tneedErrorCode(t, "user:error", func() { api.DomainDKIMRemove(ctxbg, "bogus.example", "testsel") })
// Aliases
alias := config.Alias{Addresses: []string{"mjl@mox.example"}}
api.AliasAdd(ctxbg, "support", "mox.example", alias)
tneedErrorCode(t, "user:error", func() { api.AliasAdd(ctxbg, "support", "mox.example", alias) }) // Already present.
tneedErrorCode(t, "user:error", func() { api.AliasAdd(ctxbg, "Support", "mox.example", alias) }) // Duplicate, canonical.
tneedErrorCode(t, "user:error", func() { api.AliasAdd(ctxbg, "support", "bogus.example", alias) }) // Unknown domain.
tneedErrorCode(t, "user:error", func() { api.AliasAdd(ctxbg, "support2", "mox.example", config.Alias{}) }) // No addresses.
api.AliasUpdate(ctxbg, "support", "mox.example", true, true, true)
tneedErrorCode(t, "user:error", func() { api.AliasUpdate(ctxbg, "bogus", "mox.example", true, true, true) }) // Unknown alias localpart.
tneedErrorCode(t, "user:error", func() { api.AliasUpdate(ctxbg, "support", "bogus.example", true, true, true) }) // Unknown alias domain.
tneedErrorCode(t, "user:error", func() {
api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"mjl2@mox.example", "mjl2@mox.example"})
}) // Cannot add twice.
api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"mjl2@mox.example"})
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"mjl2@mox.example"}) }) // Already present.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"bogus@mox.example"}) }) // Unknown dest localpart.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"bogus@bogus.example"}) }) // Unknown dest domain.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support2", "mox.example", []string{"mjl@mox.example"}) }) // Unknown alias localpart.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support", "bogus.example", []string{"mjl@mox.example"}) }) // Unknown alias localpart.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesAdd(ctxbg, "support", "mox.example", []string{"support@mox.example"}) }) // Alias cannot be destination.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesRemove(ctxbg, "support", "mox.example", []string{}) }) // Need at least 1 address.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesRemove(ctxbg, "support", "mox.example", []string{"bogus@mox.example"}) }) // Not a member.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesRemove(ctxbg, "support", "mox.example", []string{"bogus@bogus.example"}) }) // Not member, unknown domain.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesRemove(ctxbg, "support2", "mox.example", []string{"mjl@mox.example"}) }) // Unknown alias localpart.
tneedErrorCode(t, "user:error", func() { api.AliasAddressesRemove(ctxbg, "support", "bogus.example", []string{"mjl@mox.example"}) }) // Unknown alias domain.
tneedErrorCode(t, "user:error", func() {
api.AliasAddressesRemove(ctxbg, "support", "mox.example", []string{"mjl@mox.example", "mjl2@mox.example"})
}) // Cannot leave zero addresses.
api.AliasAddressesRemove(ctxbg, "support", "mox.example", []string{"mjl@mox.example"})
api.AliasRemove(ctxbg, "support", "mox.example") // Restore.
tneedErrorCode(t, "user:error", func() { api.AliasRemove(ctxbg, "support", "mox.example") }) // No longer exists.
tneedErrorCode(t, "user:error", func() { api.AliasRemove(ctxbg, "support", "bogus.example") }) // Unknown alias domain.
}
func TestCheckDomain(t *testing.T) {

View file

@ -159,6 +159,13 @@
"{}",
"string"
]
},
{
"Name": "localpartAliases",
"Typewords": [
"{}",
"Alias"
]
}
]
},
@ -1898,6 +1905,139 @@
}
],
"Returns": []
},
{
"Name": "AliasAdd",
"Docs": "",
"Params": [
{
"Name": "aliaslp",
"Typewords": [
"string"
]
},
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "alias",
"Typewords": [
"Alias"
]
}
],
"Returns": []
},
{
"Name": "AliasUpdate",
"Docs": "",
"Params": [
{
"Name": "aliaslp",
"Typewords": [
"string"
]
},
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "postPublic",
"Typewords": [
"bool"
]
},
{
"Name": "listMembers",
"Typewords": [
"bool"
]
},
{
"Name": "allowMsgFrom",
"Typewords": [
"bool"
]
}
],
"Returns": []
},
{
"Name": "AliasRemove",
"Docs": "",
"Params": [
{
"Name": "aliaslp",
"Typewords": [
"string"
]
},
{
"Name": "domainName",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "AliasAddressesAdd",
"Docs": "",
"Params": [
{
"Name": "aliaslp",
"Typewords": [
"string"
]
},
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "addresses",
"Typewords": [
"[]",
"string"
]
}
],
"Returns": []
},
{
"Name": "AliasAddressesRemove",
"Docs": "",
"Params": [
{
"Name": "aliaslp",
"Typewords": [
"string"
]
},
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "addresses",
"Typewords": [
"[]",
"string"
]
}
],
"Returns": []
}
],
"Sections": [],
@ -3234,6 +3374,21 @@
"[]",
"Route"
]
},
{
"Name": "Aliases",
"Docs": "",
"Typewords": [
"{}",
"Alias"
]
},
{
"Name": "Domain",
"Docs": "",
"Typewords": [
"Domain"
]
}
]
},
@ -3533,6 +3688,222 @@
}
]
},
{
"Name": "Alias",
"Docs": "",
"Fields": [
{
"Name": "Addresses",
"Docs": "",
"Typewords": [
"[]",
"string"
]
},
{
"Name": "PostPublic",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "ListMembers",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "AllowMsgFrom",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "LocalpartStr",
"Docs": "In encoded form.",
"Typewords": [
"string"
]
},
{
"Name": "Domain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "ParsedAddresses",
"Docs": "Matches addresses.",
"Typewords": [
"[]",
"AliasAddress"
]
}
]
},
{
"Name": "AliasAddress",
"Docs": "",
"Fields": [
{
"Name": "Address",
"Docs": "Parsed address.",
"Typewords": [
"Address"
]
},
{
"Name": "AccountName",
"Docs": "Looked up.",
"Typewords": [
"string"
]
},
{
"Name": "Destination",
"Docs": "Belonging to address.",
"Typewords": [
"Destination"
]
}
]
},
{
"Name": "Address",
"Docs": "Address is a parsed email address.",
"Fields": [
{
"Name": "Localpart",
"Docs": "",
"Typewords": [
"Localpart"
]
},
{
"Name": "Domain",
"Docs": "todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.",
"Typewords": [
"Domain"
]
}
]
},
{
"Name": "Destination",
"Docs": "",
"Fields": [
{
"Name": "Mailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Rulesets",
"Docs": "",
"Typewords": [
"[]",
"Ruleset"
]
},
{
"Name": "FullName",
"Docs": "",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Ruleset",
"Docs": "",
"Fields": [
{
"Name": "SMTPMailFromRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "MsgFromRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "VerifiedDomain",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "HeadersRegexp",
"Docs": "",
"Typewords": [
"{}",
"string"
]
},
{
"Name": "IsForward",
"Docs": "todo: once we implement ARC, we can use dkim domains that we cannot verify but that the arc-verified forwarding mail server was able to verify.",
"Typewords": [
"bool"
]
},
{
"Name": "ListAllowDomain",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "AcceptRejectsToMailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Mailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Comment",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "VerifiedDNSDomain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "ListAllowDNSDomain",
"Docs": "",
"Typewords": [
"Domain"
]
}
]
},
{
"Name": "Account",
"Docs": "",
@ -3682,6 +4053,14 @@
"Typewords": [
"Domain"
]
},
{
"Name": "Aliases",
"Docs": "",
"Typewords": [
"[]",
"AddressAlias"
]
}
]
},
@ -3733,118 +4112,6 @@
}
]
},
{
"Name": "Destination",
"Docs": "",
"Fields": [
{
"Name": "Mailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Rulesets",
"Docs": "",
"Typewords": [
"[]",
"Ruleset"
]
},
{
"Name": "FullName",
"Docs": "",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Ruleset",
"Docs": "",
"Fields": [
{
"Name": "SMTPMailFromRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "MsgFromRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "VerifiedDomain",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "HeadersRegexp",
"Docs": "",
"Typewords": [
"{}",
"string"
]
},
{
"Name": "IsForward",
"Docs": "todo: once we implement ARC, we can use dkim domains that we cannot verify but that the arc-verified forwarding mail server was able to verify.",
"Typewords": [
"bool"
]
},
{
"Name": "ListAllowDomain",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "AcceptRejectsToMailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Mailbox",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Comment",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "VerifiedDNSDomain",
"Docs": "",
"Typewords": [
"Domain"
]
},
{
"Name": "ListAllowDNSDomain",
"Docs": "",
"Typewords": [
"Domain"
]
}
]
},
{
"Name": "SubjectPass",
"Docs": "",
@ -3954,6 +4221,34 @@
}
]
},
{
"Name": "AddressAlias",
"Docs": "",
"Fields": [
{
"Name": "SubscriptionAddress",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Alias",
"Docs": "Without members.",
"Typewords": [
"Alias"
]
},
{
"Name": "MemberAddresses",
"Docs": "Only if allowed to see.",
"Typewords": [
"[]",
"string"
]
}
]
},
{
"Name": "PolicyRecord",
"Docs": "PolicyRecord is a cached policy or absence of a policy.",

View file

@ -275,6 +275,8 @@ export interface ConfigDomain {
MTASTS?: MTASTS | null
TLSRPT?: TLSRPT | null
Routes?: Route[] | null
Aliases?: { [key: string]: Alias }
Domain: Domain
}
export interface DKIM {
@ -333,38 +335,26 @@ export interface Route {
ToDomainASCII?: string[] | null
}
export interface Account {
OutgoingWebhook?: OutgoingWebhook | null
IncomingWebhook?: IncomingWebhook | null
FromIDLoginAddresses?: string[] | null
KeepRetiredMessagePeriod: number
KeepRetiredWebhookPeriod: number
Domain: string
Description: string
FullName: string
Destinations?: { [key: string]: Destination }
SubjectPass: SubjectPass
QuotaMessageSize: number
RejectsMailbox: string
KeepRejects: boolean
AutomaticJunkFlags: AutomaticJunkFlags
JunkFilter?: JunkFilter | null // todo: sane defaults for junkfilter
MaxOutgoingMessagesPerDay: number
MaxFirstTimeRecipientsPerDay: number
NoFirstTimeSenderDelay: boolean
Routes?: Route[] | null
DNSDomain: Domain // Parsed form of Domain.
export interface Alias {
Addresses?: string[] | null
PostPublic: boolean
ListMembers: boolean
AllowMsgFrom: boolean
LocalpartStr: string // In encoded form.
Domain: Domain
ParsedAddresses?: AliasAddress[] | null // Matches addresses.
}
export interface OutgoingWebhook {
URL: string
Authorization: string
Events?: string[] | null
export interface AliasAddress {
Address: Address // Parsed address.
AccountName: string // Looked up.
Destination: Destination // Belonging to address.
}
export interface IncomingWebhook {
URL: string
Authorization: string
// Address is a parsed email address.
export interface Address {
Localpart: Localpart
Domain: Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
}
export interface Destination {
@ -387,6 +377,41 @@ export interface Ruleset {
ListAllowDNSDomain: Domain
}
export interface Account {
OutgoingWebhook?: OutgoingWebhook | null
IncomingWebhook?: IncomingWebhook | null
FromIDLoginAddresses?: string[] | null
KeepRetiredMessagePeriod: number
KeepRetiredWebhookPeriod: number
Domain: string
Description: string
FullName: string
Destinations?: { [key: string]: Destination }
SubjectPass: SubjectPass
QuotaMessageSize: number
RejectsMailbox: string
KeepRejects: boolean
AutomaticJunkFlags: AutomaticJunkFlags
JunkFilter?: JunkFilter | null // todo: sane defaults for junkfilter
MaxOutgoingMessagesPerDay: number
MaxFirstTimeRecipientsPerDay: number
NoFirstTimeSenderDelay: boolean
Routes?: Route[] | null
DNSDomain: Domain // Parsed form of Domain.
Aliases?: AddressAlias[] | null
}
export interface OutgoingWebhook {
URL: string
Authorization: string
Events?: string[] | null
}
export interface IncomingWebhook {
URL: string
Authorization: string
}
export interface SubjectPass {
Period: number // todo: have a reasonable default for this?
}
@ -409,6 +434,12 @@ export interface JunkFilter {
RareWords: number
}
export interface AddressAlias {
SubscriptionAddress: string
Alias: Alias // Without members.
MemberAddresses?: string[] | null // Only if allowed to see.
}
// PolicyRecord is a cached policy or absence of a policy.
export interface PolicyRecord {
Domain: string // Domain name, with unicode characters.
@ -1146,7 +1177,7 @@ export enum SPFResult {
// be an IPv4 address.
export type IP = string
export const structTypes: {[typename: string]: boolean} = {"Account":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"CSRFToken":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true}
export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = {
@ -1181,7 +1212,7 @@ export const types: TypenameMap = {
"AutoconfCheckResult": {"Name":"AutoconfCheckResult","Docs":"","Fields":[{"Name":"ClientSettingsDomainIPs","Docs":"","Typewords":["[]","string"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]},
"AutodiscoverCheckResult": {"Name":"AutodiscoverCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","AutodiscoverSRV"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]},
"AutodiscoverSRV": {"Name":"AutodiscoverSRV","Docs":"","Fields":[{"Name":"Target","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["uint16"]},{"Name":"Priority","Docs":"","Typewords":["uint16"]},{"Name":"Weight","Docs":"","Typewords":["uint16"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]}]},
"ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]}]},
"ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"Aliases","Docs":"","Typewords":["{}","Alias"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"DKIM": {"Name":"DKIM","Docs":"","Fields":[{"Name":"Selectors","Docs":"","Typewords":["{}","Selector"]},{"Name":"Sign","Docs":"","Typewords":["[]","string"]}]},
"Selector": {"Name":"Selector","Docs":"","Fields":[{"Name":"Hash","Docs":"","Typewords":["string"]},{"Name":"HashEffective","Docs":"","Typewords":["string"]},{"Name":"Canonicalization","Docs":"","Typewords":["Canonicalization"]},{"Name":"Headers","Docs":"","Typewords":["[]","string"]},{"Name":"HeadersEffective","Docs":"","Typewords":["[]","string"]},{"Name":"DontSealHeaders","Docs":"","Typewords":["bool"]},{"Name":"Expiration","Docs":"","Typewords":["string"]},{"Name":"PrivateKeyFile","Docs":"","Typewords":["string"]},{"Name":"Algorithm","Docs":"","Typewords":["string"]}]},
"Canonicalization": {"Name":"Canonicalization","Docs":"","Fields":[{"Name":"HeaderRelaxed","Docs":"","Typewords":["bool"]},{"Name":"BodyRelaxed","Docs":"","Typewords":["bool"]}]},
@ -1189,14 +1220,18 @@ export const types: TypenameMap = {
"MTASTS": {"Name":"MTASTS","Docs":"","Fields":[{"Name":"PolicyID","Docs":"","Typewords":["string"]},{"Name":"Mode","Docs":"","Typewords":["Mode"]},{"Name":"MaxAge","Docs":"","Typewords":["int64"]},{"Name":"MX","Docs":"","Typewords":["[]","string"]}]},
"TLSRPT": {"Name":"TLSRPT","Docs":"","Fields":[{"Name":"Localpart","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"ParsedLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]},
"Route": {"Name":"Route","Docs":"","Fields":[{"Name":"FromDomain","Docs":"","Typewords":["[]","string"]},{"Name":"ToDomain","Docs":"","Typewords":["[]","string"]},{"Name":"MinimumAttempts","Docs":"","Typewords":["int32"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"FromDomainASCII","Docs":"","Typewords":["[]","string"]},{"Name":"ToDomainASCII","Docs":"","Typewords":["[]","string"]}]},
"Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]},
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]},
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]},
"Alias": {"Name":"Alias","Docs":"","Fields":[{"Name":"Addresses","Docs":"","Typewords":["[]","string"]},{"Name":"PostPublic","Docs":"","Typewords":["bool"]},{"Name":"ListMembers","Docs":"","Typewords":["bool"]},{"Name":"AllowMsgFrom","Docs":"","Typewords":["bool"]},{"Name":"LocalpartStr","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]},{"Name":"ParsedAddresses","Docs":"","Typewords":["[]","AliasAddress"]}]},
"AliasAddress": {"Name":"AliasAddress","Docs":"","Fields":[{"Name":"Address","Docs":"","Typewords":["Address"]},{"Name":"AccountName","Docs":"","Typewords":["string"]},{"Name":"Destination","Docs":"","Typewords":["Destination"]}]},
"Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Localpart","Docs":"","Typewords":["Localpart"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]},
"Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]},
"Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]},
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]},
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]},
"SubjectPass": {"Name":"SubjectPass","Docs":"","Fields":[{"Name":"Period","Docs":"","Typewords":["int64"]}]},
"AutomaticJunkFlags": {"Name":"AutomaticJunkFlags","Docs":"","Fields":[{"Name":"Enabled","Docs":"","Typewords":["bool"]},{"Name":"JunkMailboxRegexp","Docs":"","Typewords":["string"]},{"Name":"NeutralMailboxRegexp","Docs":"","Typewords":["string"]},{"Name":"NotJunkMailboxRegexp","Docs":"","Typewords":["string"]}]},
"JunkFilter": {"Name":"JunkFilter","Docs":"","Fields":[{"Name":"Threshold","Docs":"","Typewords":["float64"]},{"Name":"Onegrams","Docs":"","Typewords":["bool"]},{"Name":"Twograms","Docs":"","Typewords":["bool"]},{"Name":"Threegrams","Docs":"","Typewords":["bool"]},{"Name":"MaxPower","Docs":"","Typewords":["float64"]},{"Name":"TopWords","Docs":"","Typewords":["int32"]},{"Name":"IgnoreWords","Docs":"","Typewords":["float64"]},{"Name":"RareWords","Docs":"","Typewords":["int32"]}]},
"AddressAlias": {"Name":"AddressAlias","Docs":"","Fields":[{"Name":"SubscriptionAddress","Docs":"","Typewords":["string"]},{"Name":"Alias","Docs":"","Typewords":["Alias"]},{"Name":"MemberAddresses","Docs":"","Typewords":["[]","string"]}]},
"PolicyRecord": {"Name":"PolicyRecord","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ValidEnd","Docs":"","Typewords":["timestamp"]},{"Name":"LastUpdate","Docs":"","Typewords":["timestamp"]},{"Name":"LastUse","Docs":"","Typewords":["timestamp"]},{"Name":"Backoff","Docs":"","Typewords":["bool"]},{"Name":"RecordID","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Mode","Docs":"","Typewords":["Mode"]},{"Name":"MX","Docs":"","Typewords":["[]","STSMX"]},{"Name":"MaxAgeSeconds","Docs":"","Typewords":["int32"]},{"Name":"Extensions","Docs":"","Typewords":["[]","Pair"]},{"Name":"PolicyText","Docs":"","Typewords":["string"]}]},
"TLSReportRecord": {"Name":"TLSReportRecord","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"FromDomain","Docs":"","Typewords":["string"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"HostReport","Docs":"","Typewords":["bool"]},{"Name":"Report","Docs":"","Typewords":["Report"]}]},
"Report": {"Name":"Report","Docs":"","Fields":[{"Name":"OrganizationName","Docs":"","Typewords":["string"]},{"Name":"DateRange","Docs":"","Typewords":["TLSRPTDateRange"]},{"Name":"ContactInfo","Docs":"","Typewords":["string"]},{"Name":"ReportID","Docs":"","Typewords":["string"]},{"Name":"Policies","Docs":"","Typewords":["[]","Result"]}]},
@ -1312,14 +1347,18 @@ export const parser = {
MTASTS: (v: any) => parse("MTASTS", v) as MTASTS,
TLSRPT: (v: any) => parse("TLSRPT", v) as TLSRPT,
Route: (v: any) => parse("Route", v) as Route,
Alias: (v: any) => parse("Alias", v) as Alias,
AliasAddress: (v: any) => parse("AliasAddress", v) as AliasAddress,
Address: (v: any) => parse("Address", v) as Address,
Destination: (v: any) => parse("Destination", v) as Destination,
Ruleset: (v: any) => parse("Ruleset", v) as Ruleset,
Account: (v: any) => parse("Account", v) as Account,
OutgoingWebhook: (v: any) => parse("OutgoingWebhook", v) as OutgoingWebhook,
IncomingWebhook: (v: any) => parse("IncomingWebhook", v) as IncomingWebhook,
Destination: (v: any) => parse("Destination", v) as Destination,
Ruleset: (v: any) => parse("Ruleset", v) as Ruleset,
SubjectPass: (v: any) => parse("SubjectPass", v) as SubjectPass,
AutomaticJunkFlags: (v: any) => parse("AutomaticJunkFlags", v) as AutomaticJunkFlags,
JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter,
AddressAlias: (v: any) => parse("AddressAlias", v) as AddressAlias,
PolicyRecord: (v: any) => parse("PolicyRecord", v) as PolicyRecord,
TLSReportRecord: (v: any) => parse("TLSReportRecord", v) as TLSReportRecord,
Report: (v: any) => parse("Report", v) as Report,
@ -1501,12 +1540,12 @@ export class Client {
}
// DomainLocalparts returns the encoded localparts and accounts configured in domain.
async DomainLocalparts(domain: string): Promise<{ [key: string]: string }> {
async DomainLocalparts(domain: string): Promise<[{ [key: string]: string }, { [key: string]: Alias }]> {
const fn: string = "DomainLocalparts"
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["{}","string"]]
const returnTypes: string[][] = [["{}","string"],["{}","Alias"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [{ [key: string]: string }, { [key: string]: Alias }]
}
// Accounts returns the names of all configured accounts.
@ -2253,6 +2292,46 @@ export class Client {
const params: any[] = [domainName, selectors, sign]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasAdd(aliaslp: string, domainName: string, alias: Alias): Promise<void> {
const fn: string = "AliasAdd"
const paramTypes: string[][] = [["string"],["string"],["Alias"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, alias]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasUpdate(aliaslp: string, domainName: string, postPublic: boolean, listMembers: boolean, allowMsgFrom: boolean): Promise<void> {
const fn: string = "AliasUpdate"
const paramTypes: string[][] = [["string"],["string"],["bool"],["bool"],["bool"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, postPublic, listMembers, allowMsgFrom]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasRemove(aliaslp: string, domainName: string): Promise<void> {
const fn: string = "AliasRemove"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasAddressesAdd(aliaslp: string, domainName: string, addresses: string[] | null): Promise<void> {
const fn: string = "AliasAddressesAdd"
const paramTypes: string[][] = [["string"],["string"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, addresses]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasAddressesRemove(aliaslp: string, domainName: string, addresses: string[] | null): Promise<void> {
const fn: string = "AliasAddressesRemove"
const paramTypes: string[][] = [["string"],["string"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, addresses]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
}
export const defaultBaseURL = (function() {

View file

@ -431,7 +431,7 @@ func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
acc, err = store.OpenEmailAuth(log, email, password)
if err != nil {
mox.LimiterFailedAuth.Add(remoteIP, t0, 1)
if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, store.ErrUnknownCredentials) {
if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) {
log.Debug("bad http basic authentication credentials")
metricResults.WithLabelValues(fn, "badauth").Inc()
authResult = "badcreds"
@ -621,15 +621,10 @@ func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.S
addresses := append(append(m.To, m.CC...), m.BCC...)
// Check if from address is allowed for account.
fromAccName, _, _, err := mox.FindAccount(from.Address.Localpart, from.Address.Domain, false)
if err == nil && fromAccName != acc.Name {
err = mox.ErrAccountNotFound
}
if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
if !mox.AllowMsgFrom(acc.Name, from.Address) {
metricSubmission.WithLabelValues("badfrom").Inc()
return resp, webapi.Error{Code: "badFrom", Message: "from-address not configured for account"}
}
xcheckf(err, "checking if from address is allowed")
if len(recipients) == 0 {
return resp, webapi.Error{Code: "noRecipients", Message: "no recipients"}

View file

@ -640,15 +640,10 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
}
// Check if from address is allowed for account.
fromAccName, _, _, err := mox.FindAccount(fromAddr.Address.Localpart, fromAddr.Address.Domain, false)
if err == nil && fromAccName != reqInfo.Account.Name {
err = mox.ErrAccountNotFound
}
if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) {
metricSubmission.WithLabelValues("badfrom").Inc()
xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`)
}
xcheckf(ctx, err, "checking if from address is allowed")
if len(recipients) == 0 {
xcheckuserf(ctx, errors.New("no recipients"), "composing message")

View file

@ -618,7 +618,7 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R
accConf, _ := acc.Conf()
loginAddr, err := smtp.ParseAddress(address)
xcheckf(ctx, err, "parsing login address")
_, _, dest, err := mox.FindAccount(loginAddr.Localpart, loginAddr.Domain, false)
_, _, _, dest, err := mox.LookupAddress(loginAddr.Localpart, loginAddr.Domain, false, false)
xcheckf(ctx, err, "looking up destination for login address")
loginName := accConf.FullName
if dest.FullName != "" {
@ -643,6 +643,18 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R
}
addresses = append(addresses, ma)
}
// User is allowed to send using alias address as message From address. Webmail
// will choose it when replying to a message sent to that address.
aliasAddrs := map[MessageAddress]bool{}
for _, a := range accConf.Aliases {
if a.Alias.AllowMsgFrom {
ma := MessageAddress{User: a.Alias.LocalpartStr, Domain: a.Alias.Domain}
if !aliasAddrs[ma] {
addresses = append(addresses, ma)
}
aliasAddrs[ma] = true
}
}
// We implicitly start a query. We use the reqctx for the transaction, because the
// transaction is passed to the query, which can be canceled.

View file

@ -479,10 +479,10 @@ behaviour.
## Admin web interface
The admin web interface helps admins set up accounts, configure addresses, and
set up new domains (with instructions to create DNS records, and with a check
to see if they are correct). Changes made through the admin web interface
updates the [domains.conf config file](../config/#hdr-domains-conf).
The admin web interface helps admins set up accounts, configure addresses,
aliases/lists, and set up new domains (with instructions to create DNS records,
and with a check to see if they are correct). Changes made through the admin web
interface updates the [domains.conf config file](../config/#hdr-domains-conf).
Received DMARC and TLS reports can be viewed, and cached MTA-STS policies
listed.