diff --git a/README.md b/README.md index bf05ecd..d2e2ed2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/config.go b/config/config.go index 2643b09..7ebc411 100644 --- a/config/config.go +++ b/config/config.go @@ -273,17 +273,18 @@ type TransportDirect struct { } type Domain struct { - Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` - ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."` - LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` - LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` - DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` - DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` - 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."` + Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` + ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."` + LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` + LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` + DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` + DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` + 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 { diff --git a/config/doc.go b/config/doc.go index d13d247..8b3d1e9 100644 --- a/config/doc.go +++ b/config/doc.go @@ -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 diff --git a/ctl.go b/ctl.go index ecb8731..c0604f5 100644 --- a/ctl.go +++ b/ctl.go @@ -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" diff --git a/ctl_test.go b/ctl_test.go index ff03e78..d9e42cc 100644 --- a/ctl_test.go +++ b/ctl_test.go @@ -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) diff --git a/doc.go b/doc.go index 4f374ad..5549a26 100644 --- a/doc.go +++ b/doc.go @@ -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. diff --git a/main.go b/main.go index 4f0324e..5d6a7cf 100644 --- a/main.go +++ b/main.go @@ -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. diff --git a/mox-/admin.go b/mox-/admin.go index 8bc56ec..28b466f 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -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 diff --git a/mox-/config.go b/mox-/config.go index dd05484..e49e4b4 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -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") diff --git a/mox-/lookup.go b/mox-/lookup.go index cbb5f5f..8eaf0a2 100644 --- a/mox-/lookup.go +++ b/mox-/lookup.go @@ -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 } diff --git a/smtpserver/alias_test.go b/smtpserver/alias_test.go new file mode 100644 index 0000000..b7b05e6 --- /dev/null +++ b/smtpserver/alias_test.go @@ -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: +To: +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: +To: +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: +To: +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: +To: + +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: +To: + +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: +To: +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: +To: + +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: +To: +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: +To: +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: +To: + +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}) + }) +} diff --git a/smtpserver/analyze.go b/smtpserver/analyze.go index e969e5a..8001b53 100644 --- a/smtpserver/analyze.go +++ b/smtpserver/analyze.go @@ -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" @@ -28,22 +29,26 @@ import ( ) type delivery struct { - tls bool - m *store.Message - dataFile *os.File - rcptAcc rcptAccount - acc *store.Account - msgTo []message.Address - msgCc []message.Address - msgFrom smtp.Address - dnsBLs []dns.Domain - dmarcUse bool - dmarcResult dmarc.Result - dkimResults []dkim.Result - iprevStatus iprev.Status + tls bool + m *store.Message + dataFile *os.File + 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 + msgFrom smtp.Address + dnsBLs []dns.Domain + dmarcUse bool + dmarcResult dmarc.Result + dkimResults []dkim.Result + iprevStatus iprev.Status } type analysis struct { + d delivery accept bool mailbox string code int @@ -75,7 +80,8 @@ const ( reasonDNSBlocklisted = "dns-blocklisted" reasonSubjectpass = "subjectpass" reasonSubjectpassError = "subjectpass-error" - reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev. + 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) { diff --git a/smtpserver/server.go b/smtpserver/server.go index e62b06e..0bb6e07 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -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)) - - // 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 + // 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]) + }) - acc, err := store.OpenAccount(log, rcptAcc.accountName) + // 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 + } + + // 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 - // ../rfc/5321:3204 - // Received-SPF header goes before Received. ../rfc/7208:2038 - m.MsgPrefix = []byte( - xmox + - "Delivered-To: " + rcptAcc.rcptTo.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()), - ) - m.Size += int64(len(m.MsgPrefix)) + for i := range la { + // ../rfc/5321:3204 + // Received-SPF header goes before Received. ../rfc/7208:2038 + la[i].d.m.MsgPrefix = []byte( + xmox + + "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(rcpt.addr.String()), + ) + 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,67 +2979,76 @@ 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. - // 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() { - hasSpace := true - var err error - if !conf.KeepRejects { - hasSpace, err = 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 { - log.Errorx("delivering spammy mail to rejects mailbox", err) - } else { - log.Info("delivered spammy mail to rejects mailbox") - } - } else { - log.Info("not storing spammy mail to full rejects mailbox") - } - }) - } else { + 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. + a.d.m.MessageHash = messagehash + a.d.acc.WithWLock(func() { + hasSpace := true + var err error + if !conf.KeepRejects { + hasSpace, err = a.d.acc.TidyRejectsMailbox(c.log, conf.RejectsMailbox) + } + if err != nil { + log.Errorx("tidying rejects mailbox", err) + } else if hasSpace { + 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") + } + } else { + log.Info("not storing spammy mail to full rejects mailbox") + } + }) } - 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,48 +3071,88 @@ 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)) - } - } - 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") - } else { - addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing") - } + addError(rcpt, code, smtp.SeOther00, false, fmt.Sprintf("failure with code %d due to special localpart", code)) return } - delivered = true - metricDelivery.WithLabelValues("delivered", a.reason).Inc() - log.Info("incoming message delivered", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom)) - - conf, _ = acc.Conf() - if conf.RejectsMailbox != "" && m.MessageID != "" { - if err := acc.RejectsRemove(log, conf.RejectsMailbox, m.MessageID); err != nil { - log.Errorx("removing message from rejects mailbox", err, slog.String("messageid", messageID)) - } - } - }) - - // Pass delivered messages to queue for DSN processing and/or hooks. - if delivered { - mr := store.FileMsgReader(m.MsgPrefix, dataFile) - part, err := 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) - log.Check(err, "queueing webhook for incoming delivery") - } } - err = acc.Close() - log.Check(err, "closing account after delivering") - acc = nil + // 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 { + 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 + ndelivered++ + metricDelivery.WithLabelValues("delivered", a0.reason).Inc() + log.Info("incoming message delivered", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom)) + + 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)) + } + } + }) + + // Pass delivered messages to queue for DSN processing and/or hooks. + if delivered { + 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, 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") + } + } + } + + // 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") } diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 4aa6a3d..2f87dcd 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -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,9 +561,9 @@ func TestSpam(t *testing.T) { } tcheck(t, err, "deliver") - checkCount("mjl2junk", 1) // In ruleset rejects mailbox. - checkCount("Rejects", 1) // Same as before. - checkEvaluationCount(t, 0) // This is not an actual accept. + ts.checkCount("mjl2junk", 1) // In ruleset rejects mailbox. + ts.checkCount("Rejects", 1) // Same as before. + checkEvaluationCount(t, 0) // This is not an actual accept. }) // Mark the messages as having good reputation. @@ -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) }) diff --git a/store/account.go b/store/account.go index 78df8b4..67e110c 100644 --- a/store/account.go +++ b/store/account.go @@ -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) diff --git a/testdata/ctl/domains.conf b/testdata/ctl/domains.conf index d684d0b..5e1d995 100644 --- a/testdata/ctl/domains.conf +++ b/testdata/ctl/domains.conf @@ -8,4 +8,5 @@ Accounts: KeepRetiredWebhookPeriod: 1h0m0s Domain: mox.example Destinations: + mjl2@mox.example: nil mjl@mox.example: nil diff --git a/testdata/httpaccount/domains.conf b/testdata/httpaccount/domains.conf index 67191f1..1af37b3 100644 --- a/testdata/httpaccount/domains.conf +++ b/testdata/httpaccount/domains.conf @@ -1,6 +1,11 @@ Domains: mox.example: LocalpartCatchallSeparator: + + Aliases: + support: + Addresses: + - mjl☺@mox.example + AllowMsgFrom: true Accounts: mjl☺: Domain: mox.example diff --git a/testdata/smtp/domains.conf b/testdata/smtp/domains.conf index b1622c1..87f04bf 100644 --- a/testdata/smtp/domains.conf +++ b/testdata/smtp/domains.conf @@ -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 diff --git a/testdata/webadmin/domains.conf b/testdata/webadmin/domains.conf index 449f11e..2224d8e 100644 --- a/testdata/webadmin/domains.conf +++ b/testdata/webadmin/domains.conf @@ -4,4 +4,5 @@ Accounts: mjl: Domain: mox.example Destinations: + mjl2@mox.example: nil mjl@mox.example: nil diff --git a/webaccount/account.js b/webaccount/account.js index 1d835b9..9741ca0 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -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(); diff --git a/webaccount/account.ts b/webaccount/account.ts index 302b2b5..c0e1d25 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -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( diff --git a/webaccount/account_test.go b/webaccount/account_test.go index 277163f..ca99dd3 100644 --- a/webaccount/account_test.go +++ b/webaccount/account_test.go @@ -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 diff --git a/webaccount/api.json b/webaccount/api.json index bff2998..2d9b9da 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -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.", diff --git a/webaccount/api.ts b/webaccount/api.ts index 5aa396a..411dfe6 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -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, } diff --git a/webadmin/admin.go b/webadmin/admin.go index 7fbcebb..ac3bbdc 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -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") +} diff --git a/webadmin/admin.js b/webadmin/admin.js index 9a37b90..add0e38 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -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]); } diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 1883ed2..e8f4d1e 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -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])) { diff --git a/webadmin/admin_test.go b/webadmin/admin_test.go index a801edd..aaa5f90 100644 --- a/webadmin/admin_test.go +++ b/webadmin/admin_test.go @@ -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) { diff --git a/webadmin/api.json b/webadmin/api.json index dd2c78d..de95e35 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -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.", diff --git a/webadmin/api.ts b/webadmin/api.ts index 69303ba..2113bda 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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() { diff --git a/webapisrv/server.go b/webapisrv/server.go index a5afc1b..d1d3aba 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -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"} diff --git a/webmail/api.go b/webmail/api.go index 30706d3..bc94764 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -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") diff --git a/webmail/view.go b/webmail/view.go index 90c8b02..96fd421 100644 --- a/webmail/view.go +++ b/webmail/view.go @@ -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. diff --git a/website/features/index.md b/website/features/index.md index b8bd569..aa59ff1 100644 --- a/website/features/index.md +++ b/website/features/index.md @@ -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.