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

the members must currently all be addresses of local accounts.

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

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

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

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

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

View file

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

View file

@ -282,8 +282,9 @@ type Domain struct {
MTASTS *MTASTS `sconf:"optional" sconf-doc:"MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy."` 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."` 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."` 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:"-"` ClientSettingsDNSDomain dns.Domain `sconf:"-" json:"-"`
// Set when DMARC and TLSRPT (when set) has an address with different domain (we're // 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:"-"` 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 { type DMARC struct {
Localpart string `sconf-doc:"Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports."` 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."` 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:"-"` NeutralMailbox *regexp.Regexp `sconf:"-" json:"-"`
NotJunkMailbox *regexp.Regexp `sconf:"-" json:"-"` NotJunkMailbox *regexp.Regexp `sconf:"-" json:"-"`
ParsedFromIDLoginAddresses []smtp.Address `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 { type JunkFilter struct {

View file

@ -913,6 +913,31 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
MinimumAttempts: 0 MinimumAttempts: 0
Transport: 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 # 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 # 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 # its own on-disk directory holding its messages and index database. An account

161
ctl.go
View file

@ -9,6 +9,7 @@ import (
"io" "io"
"log" "log"
"log/slog" "log/slog"
"maps"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@ -20,6 +21,7 @@ import (
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message" "github.com/mjl-/mox/message"
"github.com/mjl-/mox/metrics" "github.com/mjl-/mox/metrics"
@ -1017,6 +1019,165 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "removing address") ctl.xcheck(err, "removing address")
ctl.xwriteok() 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": case "loglevels":
/* protocol: /* protocol:
> "loglevels" > "loglevels"

View file

@ -12,6 +12,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dmarcdb" "github.com/mjl-/mox/dmarcdb"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
@ -292,6 +293,41 @@ func TestCtl(t *testing.T) {
ctlcmdConfigDomainRemove(ctl, dns.Domain{ASCII: "mox2.example"}) 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" // "loglevels"
testctl(func(ctl *ctl) { testctl(func(ctl *ctl) {
ctlcmdLoglevels(ctl) ctlcmdLoglevels(ctl)

55
doc.go
View file

@ -68,6 +68,13 @@ any parameters. Followed by the help and usage information for each command.
mox config address rm address mox config address rm address
mox config domain add domain account [localpart] mox config domain add domain account [localpart]
mox config domain rm domain 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 describe-sendmail >/etc/moxsubmit.conf
mox config printservice >mox.service mox config printservice >mox.service
mox config ensureacmehostprivatekeys mox config ensureacmehostprivatekeys
@ -968,6 +975,54 @@ rejected.
usage: mox config domain rm domain 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 # mox config describe-sendmail
Describe configuration for mox when invoked as sendmail. Describe configuration for mox when invoked as sendmail.

149
main.go
View file

@ -148,6 +148,14 @@ var commands = []struct {
{"config address rm", cmdConfigAddressRemove}, {"config address rm", cmdConfigAddressRemove},
{"config domain add", cmdConfigDomainAdd}, {"config domain add", cmdConfigDomainAdd},
{"config domain rm", cmdConfigDomainRemove}, {"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 describe-sendmail", cmdConfigDescribeSendmail},
{"config printservice", cmdConfigPrintservice}, {"config printservice", cmdConfigPrintservice},
{"config ensureacmehostprivatekeys", cmdConfigEnsureACMEHostprivatekeys}, {"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) 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) { func cmdConfigAccountAdd(c *cmd) {
c.params = "account address" c.params = "account address"
c.help = `Add an account with an email address and reload the configuration. c.help = `Add an account with an email address and reload the configuration.

View file

@ -17,6 +17,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings" "strings"
"time" "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. // can modify the config, but must clone all referencing data it changes.
// xmodify may employ panic-based error handling. After xmodify returns, the // xmodify may employ panic-based error handling. After xmodify returns, the
// modified config is verified, saved and takes effect. // 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) log := pkglog.WithContext(ctx)
defer func() { defer func() {
if rerr != nil { 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) 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 // Compose new config without modifying existing data structures. If we fail, we
// leave no trace. // leave no trace.
@ -1031,14 +1034,17 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
// //
// Must be called with config lock held. // Must be called with config lock held.
func checkAddressAvailable(addr smtp.Address) error { 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") return fmt.Errorf("domain does not exist")
} else if lp, err := CanonicalLocalpart(addr.Localpart, dc); err != nil { }
return fmt.Errorf("canonicalizing localpart: %v", err) lp := CanonicalLocalpart(addr.Localpart, dc)
} else if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok { if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain)) return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) { } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
return fmt.Errorf("localpart cannot include domain catchall separator %s", 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 return nil
} }
@ -1177,14 +1183,8 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if !ok { if !ok {
return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true)) return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
} }
flp, err := CanonicalLocalpart(fa.Localpart, dc) flp := CanonicalLocalpart(fa.Localpart, dc)
if err != nil { alp := CanonicalLocalpart(pa.Localpart, dc)
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)
}
if alp != flp { if alp != flp {
// Keep for different localpart. // Keep for different localpart.
fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i]) fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
@ -1206,6 +1206,88 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
return nil 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 // 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 // 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 // change referencing fields (e.g. existing slice/map/pointer), they may still be

View file

@ -80,6 +80,8 @@ type Config struct {
// case-insensitive, stripped of catchall separator) to account and address. // case-insensitive, stripped of catchall separator) to account and address.
// Domains are IDNA names in utf8. // Domains are IDNA names in utf8.
accountDestinations map[string]AccountDestination accountDestinations map[string]AccountDestination
// Like accountDestinations, but for aliases.
aliases map[string]config.Alias
} }
type AccountDestination struct { type AccountDestination struct {
@ -152,13 +154,14 @@ func (c *Config) withDynamicLock(fn func()) {
// must be called with dynamic lock held. // must be called with dynamic lock held.
func (c *Config) loadDynamic() []error { 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 { if err != nil {
return err return err
} }
c.Dynamic = d c.Dynamic = d
c.dynamicMtime = mtime c.dynamicMtime = mtime
c.accountDestinations = accDests c.accountDestinations = accDests
c.aliases = aliases
c.allowACMEHosts(pkglog, true) c.allowACMEHosts(pkglog, true)
return nil return nil
} }
@ -193,10 +196,12 @@ func (c *Config) Accounts() (l []string) {
} }
// DomainLocalparts returns a mapping of encoded localparts to account names for a // DomainLocalparts returns a mapping of encoded localparts to account names for a
// domain. An empty localpart is a catchall destination for a domain. // domain, and encoded localparts to aliases. An empty localpart is a catchall
func (c *Config) DomainLocalparts(d dns.Domain) map[string]string { // destination for a domain.
func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]config.Alias) {
suffix := "@" + d.Name() suffix := "@" + d.Name()
m := map[string]string{} m := map[string]string{}
aliases := map[string]config.Alias{}
c.withDynamicLock(func() { c.withDynamicLock(func() {
for addr, ad := range c.accountDestinations { for addr, ad := range c.accountDestinations {
if strings.HasSuffix(addr, suffix) { if strings.HasSuffix(addr, suffix) {
@ -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) { 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 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() { 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 return
} }
@ -314,7 +331,7 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
// must be called with lock held. // must be called with lock held.
// Returns ErrConfig if the configuration is not valid. // Returns ErrConfig if the configuration is not valid.
func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error { 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 { if len(errs) > 0 {
return fmt.Errorf("%w: %v", ErrConfig, 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.DynamicLastCheck = time.Now()
Conf.Dynamic = c Conf.Dynamic = c
Conf.accountDestinations = accDests Conf.accountDestinations = accDests
Conf.aliases = aliases
Conf.allowACMEHosts(log, true) 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. // SetConfig sets a new config. Not to be used during normal operation.
func SetConfig(c *Config) { func SetConfig(c *Config) {
// Cannot just assign *c to Conf, it would copy the mutex. // 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 we have non-standard CA roots, use them for all HTTPS requests.
if Conf.Static.TLS.CertPool != nil { 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") 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 { if !checkOnly {
c.allowACMEHosts(log, checkACMEHosts) 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. // 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) { addErrorf := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...)) errs = append(errs, fmt.Errorf(format, args...))
} }
@ -1012,11 +1030,11 @@ func ParseDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, s
return return
} }
accDests, errs = prepareDynamicConfig(ctx, log, dynamicPath, static, &c) accDests, aliases, errs = prepareDynamicConfig(ctx, log, dynamicPath, static, &c)
return c, fi.ModTime(), accDests, errs 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) { addErrorf := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...)) 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") checkMailboxNormf(static.Postmaster.Mailbox, "postmaster mailbox")
accDests = map[string]AccountDestination{} accDests = map[string]AccountDestination{}
aliases = map[string]config.Alias{}
// Validate host TLSRPT account/address. // Validate host TLSRPT account/address.
if static.HostTLSRPT.Account != "" { if static.HostTLSRPT.Account != "" {
@ -1287,6 +1306,9 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
acc.ParsedFromIDLoginAddresses[i] = a acc.ParsedFromIDLoginAddresses[i] = a
} }
// Clear any previously derived state.
acc.Aliases = nil
c.Accounts[accName] = acc c.Accounts[accName] = acc
if acc.OutgoingWebhook != nil { if acc.OutgoingWebhook != nil {
@ -1445,9 +1467,8 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
origLP := address.Localpart origLP := address.Localpart
dc := c.Domains[address.Domain.Name()] dc := c.Domains[address.Domain.Name()]
domainHasAddress[address.Domain.Name()] = true domainHasAddress[address.Domain.Name()] = true
if lp, err := CanonicalLocalpart(address.Localpart, dc); err != nil { lp := CanonicalLocalpart(address.Localpart, dc)
addErrorf("canonicalizing localpart %s: %v", address.Localpart, err) if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) {
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) {
addErrorf("localpart of address %s includes domain catchall separator %s", address, dc.LocalpartCatchallSeparator) addErrorf("localpart of address %s includes domain catchall separator %s", address, dc.LocalpartCatchallSeparator)
} else { } else {
address.Localpart = lp address.Localpart = lp
@ -1481,12 +1502,7 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
continue continue
} }
dc := c.Domains[a.Domain.Name()] dc := c.Domains[a.Domain.Name()]
lp, err := CanonicalLocalpart(a.Localpart, dc) a.Localpart = 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
if _, ok := accDests[a.Pack(true)]; !ok { 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) 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 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. // Check webserver configs.
if (len(c.WebDomainRedirects) > 0 || len(c.WebHandlers) > 0) && !haveWebserverListener { if (len(c.WebDomainRedirects) > 0 || len(c.WebHandlers) > 0) && !haveWebserverListener {
addErrorf("WebDomainRedirects or WebHandlers configured but no listener with WebserverHTTP or WebserverHTTPS enabled") addErrorf("WebDomainRedirects or WebHandlers configured but no listener with WebserverHTTP or WebserverHTTPS enabled")

View file

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

292
smtpserver/alias_test.go Normal file
View file

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

View file

@ -11,6 +11,7 @@ import (
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc" "github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dmarcrpt" "github.com/mjl-/mox/dmarcrpt"
@ -31,7 +32,10 @@ type delivery struct {
tls bool tls bool
m *store.Message m *store.Message
dataFile *os.File dataFile *os.File
rcptAcc rcptAccount smtpRcptTo smtp.Path // As used in SMTP, possibly address of alias.
deliverTo smtp.Path // To deliver to, either smtpRcptTo or an alias member address.
destination config.Destination
canonicalAddress string
acc *store.Account acc *store.Account
msgTo []message.Address msgTo []message.Address
msgCc []message.Address msgCc []message.Address
@ -44,6 +48,7 @@ type delivery struct {
} }
type analysis struct { type analysis struct {
d delivery
accept bool accept bool
mailbox string mailbox string
code int code int
@ -76,6 +81,7 @@ const (
reasonSubjectpass = "subjectpass" reasonSubjectpass = "subjectpass"
reasonSubjectpassError = "subjectpass-error" 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 { 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 { func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d delivery) analysis {
var headers string 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 == "" { if mailbox == "" {
mailbox = "Inbox" mailbox = "Inbox"
} }
// If destination mailbox has a mailing list domain (for SPF/DKIM) configured, // If destination mailbox has a mailing list domain (for SPF/DKIM) configured,
// check it for a pass. // 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 { if rs != nil {
mailbox = rs.Mailbox 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? // todo: on temporary failures, reject temporarily?
if isListDomain(d, rs.ListAllowDNSDomain) { if isListDomain(d, rs.ListAllowDNSDomain) {
d.m.IsMailingList = true 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 { 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. 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 d.m.Seen = true
log.Info("accepting reject to configured mailbox due to ruleset") 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 { 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 // If destination is the DMARC reporting mailbox, do additional checks and keep
// track of the report. We'll check reputation, defaulting to accept. // track of the report. We'll check reputation, defaulting to accept.
var dmarcReport *dmarcrpt.Feedback 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 // Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
if d.dmarcResult.Status != dmarc.StatusPass { if d.dmarcResult.Status != dmarc.StatusPass {
log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report") 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 // Similar to DMARC reporting, we check for the required DKIM. We'll check
// reputation, defaulting to accept. // reputation, defaulting to accept.
var tlsReport *tlsrpt.Report 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 { matchesDomain := func(sigDomain dns.Domain) bool {
// RFC seems to require exact DKIM domain match with submitt and message From, we // RFC seems to require exact DKIM domain match with submitt and message From, we
// also allow msgFrom to be subdomain. ../rfc/8460:322 // 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 conclusive bool
var method reputationMethod var method reputationMethod
var reason string var reason string
var err error
d.acc.WithRLock(func() { d.acc.WithRLock(func() {
err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error { err = d.acc.DB.Read(ctx, func(tx *bstore.Tx) error {
if err := assignMailbox(tx); err != nil { 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))) slog.String("method", string(method)))
if conclusive { if conclusive {
if !*isjunk { 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)) return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
} else if dmarcReport != nil || tlsReport != nil { } else if dmarcReport != nil || tlsReport != nil {
log.Info("accepting message with dmarc aggregate report or tls report without reputation") 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 // If there was no previous message from sender or its domain, and we have an SPF
// (soft)fail, reject the message. // (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 var subjectpassKey string
conf, _ := d.acc.Conf() conf, _ := d.acc.Conf()
if conf.SubjectPass.Period > 0 { if conf.SubjectPass.Period > 0 {
subjectpassKey, err = d.acc.Subjectpass(d.rcptAcc.canonicalAddress) subjectpassKey, err = d.acc.Subjectpass(d.canonicalAddress)
if err != nil { if err != nil {
log.Errorx("get key for verifying subject token", err) log.Errorx("get key for verifying subject token", err)
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, reasonSubjectpassError) 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 pass := err == nil
log.Infox("pass by subject token", err, slog.Bool("pass", pass)) log.Infox("pass by subject token", err, slog.Bool("pass", pass))
if 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 { 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 // 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 return true
} }
for _, a := range l { for _, a := range l {
@ -389,7 +477,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
continue continue
} }
lp, err := smtp.ParseLocalpart(a.User) 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 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 // 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. // sent with matching Bcc headers. We don't get here for known senders.
threshold = 0.25 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)) log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", slog.Float64("threshold", threshold))
reason = reasonJunkContentStrict reason = reasonJunkContentStrict
} }
@ -463,7 +552,7 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver
} }
if accept { 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) { if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {

View file

@ -336,19 +336,30 @@ type conn struct {
has8bitmime bool // If MAIL FROM parameter BODY=8BITMIME was sent. Required for SMTPUTF8. 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. 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. 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 { type rcptAccount struct {
rcptTo smtp.Path
local bool // Whether recipient is a local user.
// Only valid for local delivery.
accountName string accountName string
destination config.Destination destination config.Destination
canonicalAddress string // Optional catchall part stripped and/or lowercased. 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 { func isClosed(err error) bool {
return errors.Is(err, errIO) || moxio.IsClosed(err) 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 { 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) return err == nil && (dest.HostTLSReports || dest.DomainTLSReports)
} }
@ -1487,7 +1498,7 @@ func (c *conn) cmdMail(p *parser) {
if rpath.IsZero() { if rpath.IsZero() {
return true 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 return err == nil && accName == c.account.Name
} }
@ -1626,10 +1637,15 @@ func (c *conn) cmdRcpt(p *parser) {
if !c.submission { if !c.submission {
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip") xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip")
} }
c.recipients = append(c.recipients, rcptAccount{fpath, false, "", config.Destination{}, ""}) c.recipients = append(c.recipients, recipient{fpath, nil, nil})
} else if accountName, canonical, addr, err := mox.FindAccount(fpath.Localpart, fpath.IPDomain.Domain, true); err == 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 FindAccount. ../rfc/5321:735 // note: a bare postmaster, without domain, is handled by LookupAddress. ../rfc/5321:735
c.recipients = append(c.recipients, rcptAccount{fpath, true, accountName, addr, canonical}) 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 { } else if Localserve {
// If the address isn't known, and we are in localserve, deliver to the mox user. // 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 // 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. // which is typically the mox user.
acc, _ := mox.Conf.Account("mox") acc, _ := mox.Conf.Account("mox")
dest := acc.Destinations["mox@localhost"] 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) { } else if errors.Is(err, mox.ErrDomainNotFound) {
if !c.submission { if !c.submission {
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain") xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain")
} }
// We'll be delivering this email. // We'll be delivering this email.
c.recipients = append(c.recipients, rcptAccount{fpath, false, "", config.Destination{}, ""}) c.recipients = append(c.recipients, recipient{fpath, nil, nil})
} else if errors.Is(err, mox.ErrAccountNotFound) { } else if errors.Is(err, mox.ErrAddressNotFound) {
if c.submission { if c.submission {
// For submission, we're transparent about which user exists. Should be fine for the typical small-scale deploy. // For submission, we're transparent about which user exists. Should be fine for the typical small-scale deploy.
// ../rfc/5321:1071 // ../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 // 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. // 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. // 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 { } else {
c.log.Errorx("looking up account for delivery", err, slog.Any("rcptto", fpath)) c.log.Errorx("looking up account for delivery", err, slog.Any("rcptto", fpath))
xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "error processing") xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "error processing")
@ -1696,7 +1712,7 @@ func (c *conn) isSMTPUTF8Required(part *message.Part) bool {
} }
// Check all "RCPT TO". // Check all "RCPT TO".
for _, rcpt := range c.recipients { for _, rcpt := range c.recipients {
if hasNonASCII(strings.NewReader(string(rcpt.rcptTo.Localpart))) { if hasNonASCII(strings.NewReader(string(rcpt.addr.Localpart))) {
return true 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)) 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) xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeMsg6Other0, "cannot parse header or From address: %v", err)
} }
accName, _, _, err := mox.FindAccount(msgFrom.Localpart, msgFrom.Domain, true) if !mox.AllowMsgFrom(c.account.Name, msgFrom) {
if err != nil || accName != c.account.Name {
// ../rfc/6409:522 // ../rfc/6409:522
if err == nil {
err = mox.ErrAccountNotFound
}
metricSubmission.WithLabelValues("badfrom").Inc() metricSubmission.WithLabelValues("badfrom").Inc()
c.log.Infox("verifying message From address", err, slog.String("user", c.username), slog.Any("msgfrom", msgFrom)) c.log.Infox("verifying message from address", mox.ErrAddressNotFound, slog.String("user", c.username), slog.Any("msgfrom", msgFrom))
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user") 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. // 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 { err = c.account.DB.Read(ctx, func(tx *bstore.Tx) error {
rcpts := make([]smtp.Path, len(c.recipients)) rcpts := make([]smtp.Path, len(c.recipients))
for i, r := range c.recipients { for i, r := range c.recipients {
rcpts[i] = r.rcptTo rcpts[i] = r.addr
} }
msglimit, rcptlimit, err := c.account.SendLimitReached(tx, rcpts) msglimit, rcptlimit, err := c.account.SendLimitReached(tx, rcpts)
xcheckf(err, "checking sender limit") 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) selectors := mox.DKIMSelectors(confDom.DKIM)
if len(selectors) > 0 { if len(selectors) > 0 {
if canonical, err := mox.CanonicalLocalpart(msgFrom.Localpart, confDom); err != nil { canonical := mox.CanonicalLocalpart(msgFrom.Localpart, confDom)
c.log.Errorx("determining canonical localpart for dkim signing", err, slog.Any("localpart", msgFrom.Localpart)) if dkimHeaders, err := dkim.Sign(ctx, c.log.Logger, canonical, msgFrom.Domain, selectors, c.msgsmtputf8, store.FileMsgReader(msgPrefix, dataFile)); err != nil {
} else 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)) c.log.Errorx("dkim sign for domain", err, slog.Any("domain", msgFrom.Domain))
metricServerErrors.WithLabelValues("dkimsign").Inc() metricServerErrors.WithLabelValues("dkimsign").Inc()
} else { } else {
@ -2091,9 +2102,9 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
} }
now := time.Now() now := time.Now()
qml := make([]queue.Msg, len(c.recipients)) qml := make([]queue.Msg, len(c.recipients))
for i, rcptAcc := range c.recipients { for i, rcpt := range c.recipients {
if Localserve { if Localserve {
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart) code, timeout := mox.LocalserveNeedsError(rcpt.addr.Localpart)
if timeout { if timeout {
c.log.Info("timing out submission due to special localpart") c.log.Info("timing out submission due to special localpart")
mox.Sleep(mox.Context, time.Hour) 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. // messages in a single smtp transaction.
var rcptTo string var rcptTo string
if len(c.recipients) == 1 { if len(c.recipients) == 1 {
rcptTo = rcptAcc.rcptTo.String() rcptTo = rcpt.addr.String()
} }
xmsgPrefix := append([]byte(recvHdrFor(rcptTo)), msgPrefix...) xmsgPrefix := append([]byte(recvHdrFor(rcptTo)), msgPrefix...)
msgSize := int64(len(xmsgPrefix)) + msgWriter.Size 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() { if !c.futureRelease.IsZero() {
qm.NextAttempt = c.futureRelease qm.NextAttempt = c.futureRelease
qm.FutureReleaseRequest = c.futureReleaseRequest 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) xsmtpServerErrorf(errCodes(smtp.C451LocalErr, smtp.SeSys3Other0, err), "error delivering message: %v", err)
} }
metricSubmission.WithLabelValues("ok").Inc() metricSubmission.WithLabelValues("ok").Inc()
for i, rcptAcc := range c.recipients { for i, rcpt := range c.recipients {
c.log.Info("messages queued for delivery", c.log.Info("messages queued for delivery",
slog.Any("mailfrom", *c.mailFrom), slog.Any("mailfrom", *c.mailFrom),
slog.Any("rcptto", rcptAcc.rcptTo), slog.Any("rcptto", rcpt.addr),
slog.Bool("smtputf8", c.smtputf8), slog.Bool("smtputf8", c.smtputf8),
slog.Bool("msgsmtputf8", c.msgsmtputf8), slog.Bool("msgsmtputf8", c.msgsmtputf8),
slog.Int64("msgsize", qml[i].Size)) slog.Int64("msgsize", qml[i].Size))
} }
err = c.account.DB.Write(ctx, func(tx *bstore.Tx) error { err = c.account.DB.Write(ctx, func(tx *bstore.Tx) error {
for _, rcptAcc := range c.recipients { for _, rcpt := range c.recipients {
outgoing := store.Outgoing{Recipient: rcptAcc.rcptTo.XString(true)} outgoing := store.Outgoing{Recipient: rcpt.addr.XString(true)}
if err := tx.Insert(&outgoing); err != nil { if err := tx.Insert(&outgoing); err != nil {
return fmt.Errorf("adding outgoing message: %v", err) 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. // Give immediate response if all recipients are unknown.
nunknown := 0 nunknown := 0
for _, r := range c.recipients { for _, r := range c.recipients {
if !r.local { if r.account == nil && r.alias == nil {
nunknown++ nunknown++
} }
} }
@ -2604,8 +2615,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
errmsg string errmsg string
} }
var deliverErrors []deliverError var deliverErrors []deliverError
addError := func(rcptAcc rcptAccount, code int, secode string, userError bool, errmsg string) { addError := func(rcpt recipient, code int, secode string, userError bool, errmsg string) {
e := deliverError{rcptAcc.rcptTo, code, secode, userError, errmsg} e := deliverError{rcpt.addr, code, secode, userError, errmsg}
c.log.Info("deliver error", c.log.Info("deliver error",
slog.Any("rcptto", e.rcptTo), slog.Any("rcptto", e.rcptTo),
slog.Int("code", code), slog.Int("code", code),
@ -2615,124 +2626,52 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
deliverErrors = append(deliverErrors, e) deliverErrors = append(deliverErrors, e)
} }
// For each recipient, do final spam analysis and delivery. // Sort recipients: local accounts, aliases, unknown. For ensuring we don't deliver
for _, rcptAcc := range c.recipients { // to an alias destination that was also explicitly sent to.
log := c.log.With(slog.Any("mailfrom", c.mailFrom), slog.Any("rcptto", rcptAcc.rcptTo)) rcptScore := func(r recipient) int {
if r.account != nil {
return 0
} else if r.alias != nil {
return 1
}
return 2
}
sort.SliceStable(c.recipients, func(i, j int) bool {
return rcptScore(c.recipients[i]) < rcptScore(c.recipients[j])
})
// If this is not a valid local user, we send back a DSN. This can only happen when // Return whether address is a regular explicit recipient in this transaction. Used
// there are also valid recipients, and only when remote is SPF-verified, so the DSN // to prevent delivering a message to an address both for alias and explicit
// should not cause backscatter. // addressee. Relies on c.recipients being sorted as above.
// In case of serious errors, we abort the transaction. We may have already regularRecipient := func(addr smtp.Path) bool {
// delivered some messages. Perhaps it would be better to continue with other for _, rcpt := range c.recipients {
// deliveries, and return an error at the end? Though the failure conditions will if rcpt.account == nil {
// probably prevent any other successful deliveries too... break
// We'll continue delivering to other recipients. ../rfc/5321:3275 } else if rcpt.addr.Equal(addr) {
if !rcptAcc.local { return true
metricDelivery.WithLabelValues("unknownuser", "").Inc() }
addError(rcptAcc, smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, true, "no such user") }
continue return false
} }
acc, err := store.OpenAccount(log, rcptAcc.accountName) // Prepare a message, analyze it against account's junk filter.
// The returned analysis has an open account that must be closed by the caller.
// We call this for all alias destinations, also when we already delivered to that
// recipient: It may be the only recipient that would allow the message.
messageAnalyze := func(log mlog.Log, smtpRcptTo, deliverTo smtp.Path, accountName string, destination config.Destination, canonicalAddr string) (a *analysis, rerr error) {
acc, err := store.OpenAccount(log, accountName)
if err != nil { 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() metricDelivery.WithLabelValues("accounterror", "").Inc()
addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing") return nil, err
continue
} }
defer func() { defer func() {
if acc != nil { if a == nil {
err := acc.Close() 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{ m := store.Message{
Received: time.Now(), Received: time.Now(),
RemoteIP: c.remoteIP.String(), RemoteIP: c.remoteIP.String(),
@ -2743,8 +2682,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
MailFrom: c.mailFrom.String(), MailFrom: c.mailFrom.String(),
MailFromLocalpart: c.mailFrom.Localpart, MailFromLocalpart: c.mailFrom.Localpart,
MailFromDomain: c.mailFrom.IPDomain.Domain.Name(), MailFromDomain: c.mailFrom.IPDomain.Domain.Name(),
RcptToLocalpart: rcptAcc.rcptTo.Localpart, RcptToLocalpart: smtpRcptTo.Localpart,
RcptToDomain: rcptAcc.rcptTo.IPDomain.Domain.Name(), RcptToDomain: smtpRcptTo.IPDomain.Domain.Name(),
MsgFromLocalpart: msgFrom.Localpart, MsgFromLocalpart: msgFrom.Localpart,
MsgFromDomain: msgFrom.Domain.Name(), MsgFromDomain: msgFrom.Domain.Name(),
MsgFromOrgDomain: publicsuffix.Lookup(ctx, log.Logger, 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 msgTo = envelope.To
msgCc = envelope.CC msgCc = envelope.CC
} }
d := delivery{c.tls, &m, dataFile, rcptAcc, acc, msgTo, msgCc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus} d := delivery{c.tls, &m, dataFile, smtpRcptTo, deliverTo, destination, canonicalAddr, acc, msgTo, msgCc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
a := analyze(ctx, log, c.resolver, d)
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 // Any DMARC result override is stored in the evaluation for outgoing DMARC
// aggregate reports, and added to the Authentication-Results message header. // 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 // they don't overestimate the potential damage of switching from p=none to
// p=reject. // p=reject.
var dmarcOverrides []string var dmarcOverrides []string
if a.dmarcOverrideReason != "" { if a0.dmarcOverrideReason != "" {
dmarcOverrides = []string{a.dmarcOverrideReason} dmarcOverrides = []string{a0.dmarcOverrideReason}
} }
if dmarcResult.Record != nil && !dmarcUse { if dmarcResult.Record != nil && !dmarcUse {
dmarcOverrides = append(dmarcOverrides, string(dmarcrpt.PolicyOverrideSampledOut)) 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. // Prepend reason as message header, for easy display in mail clients.
var xmox string var xmox string
if a.reason != "" { if a0.reason != "" {
xmox = "X-Mox-Reason: " + a.reason + "\r\n" xmox = "X-Mox-Reason: " + a0.reason + "\r\n"
} }
xmox += a.headers xmox += a0.headers
for i := range la {
// ../rfc/5321:3204 // ../rfc/5321:3204
// Received-SPF header goes before Received. ../rfc/7208:2038 // Received-SPF header goes before Received. ../rfc/7208:2038
m.MsgPrefix = []byte( la[i].d.m.MsgPrefix = []byte(
xmox + xmox +
"Delivered-To: " + rcptAcc.rcptTo.XString(c.msgsmtputf8) + "\r\n" + // ../rfc/9228:274 "Delivered-To: " + la[i].d.deliverTo.XString(c.msgsmtputf8) + "\r\n" + // ../rfc/9228:274
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300 "Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
rcptAuthResults.Header() + rcptAuthResults.Header() +
receivedSPF.Header() + receivedSPF.Header() +
recvHdrFor(rcptAcc.rcptTo.String()), recvHdrFor(rcpt.addr.String()),
) )
m.Size += int64(len(m.MsgPrefix)) la[i].d.m.Size += int64(len(la[i].d.m.MsgPrefix))
}
// Store DMARC evaluation for inclusion in an aggregate report. Only if there is at // 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 // 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 // the analysis, we will report on rejects because of DMARC, because it could be
// valuable feedback about forwarded or mailing list messages. // valuable feedback about forwarded or mailing list messages.
// ../rfc/7489:1492 // ../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 // 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, // DMARC evaluation resulted in. We can override, e.g. because of mailing lists,
// forwarding, or local policy. // forwarding, or local policy.
// We treat quarantine as reject, so never claim to quarantine. // We treat quarantine as reject, so never claim to quarantine.
// ../rfc/7489:1691 // ../rfc/7489:1691
disposition := dmarcrpt.DispositionNone disposition := dmarcrpt.DispositionNone
if !a.accept { if !a0.accept {
disposition = dmarcrpt.DispositionReject disposition = dmarcrpt.DispositionReject
} }
// unknownDomain returns whether the sender is domain with which this account has // unknownDomain returns whether the sender is domain with which this account has
// not had positive interaction. // not had positive interaction.
unknownDomain := func() (unknown bool) { 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. // See if we received a non-junk message from this organizational domain.
q := bstore.QueryTx[store.Message](tx) 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("Notjunk", true)
q.FilterEqual("IsReject", false) q.FilterEqual("IsReject", false)
exists, err := q.Exists() 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. // See if we sent a message to this organizational domain.
qr := bstore.QueryTx[store.Recipient](tx) 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() exists, err = qr.Exists()
if err != nil { if err != nil {
return fmt.Errorf("querying for message sent to organizational domain: %v", err) 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 // loop. We also don't want to be used for sending reports to unsuspecting domains
// we have no relation with. // 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. // 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, Addresses: addresses,
@ -2911,7 +2934,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
Disposition: disposition, Disposition: disposition,
AlignedDKIMPass: dmarcResult.AlignedDKIMPass, AlignedDKIMPass: dmarcResult.AlignedDKIMPass,
AlignedSPFPass: dmarcResult.AlignedSPFPass, AlignedSPFPass: dmarcResult.AlignedSPFPass,
EnvelopeTo: rcptAcc.rcptTo.IPDomain.String(), EnvelopeTo: rcpt.addr.IPDomain.String(),
EnvelopeFrom: c.mailFrom.IPDomain.String(), EnvelopeFrom: c.mailFrom.IPDomain.String(),
HeaderFrom: msgFrom.Domain.Name(), HeaderFrom: msgFrom.Domain.Name(),
} }
@ -2956,29 +2979,41 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
log.Check(err, "adding dmarc evaluation to database for aggregate report") log.Check(err, "adding dmarc evaluation to database for aggregate report")
} }
conf, _ := acc.Conf() if !a0.accept {
if !a.accept { for _, a := range la {
if conf.RejectsMailbox != "" { // Don't add message if address was also explicitly present in a RCPT TO command.
present, _, messagehash, err := rejectPresent(log, acc, conf.RejectsMailbox, &m, dataFile) 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 { if err != nil {
log.Errorx("checking whether reject is already present", err) log.Errorx("checking whether reject is already present", err)
} else if !present { continue
m.IsReject = true } else if present {
m.Seen = true // We don't want to draw attention. 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 // Regular automatic junk flags configuration applies to these messages. The
// default is to treat these as neutral, so they won't cause outright rejections // default is to treat these as neutral, so they won't cause outright rejections
// due to reputation for later delivery attempts. // due to reputation for later delivery attempts.
m.MessageHash = messagehash a.d.m.MessageHash = messagehash
acc.WithWLock(func() { a.d.acc.WithWLock(func() {
hasSpace := true hasSpace := true
var err error var err error
if !conf.KeepRejects { if !conf.KeepRejects {
hasSpace, err = acc.TidyRejectsMailbox(c.log, conf.RejectsMailbox) hasSpace, err = a.d.acc.TidyRejectsMailbox(c.log, conf.RejectsMailbox)
} }
if err != nil { if err != nil {
log.Errorx("tidying rejects mailbox", err) log.Errorx("tidying rejects mailbox", err)
} else if hasSpace { } else if hasSpace {
if err := acc.DeliverMailbox(log, conf.RejectsMailbox, &m, dataFile); err != nil { if err := a.d.acc.DeliverMailbox(log, conf.RejectsMailbox, a.d.m, dataFile); err != nil {
log.Errorx("delivering spammy mail to rejects mailbox", err) log.Errorx("delivering spammy mail to rejects mailbox", err)
} else { } else {
log.Info("delivered spammy mail to rejects mailbox") log.Info("delivered spammy mail to rejects mailbox")
@ -2987,36 +3022,33 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
log.Info("not storing spammy mail to full rejects mailbox") log.Info("not storing spammy mail to full rejects mailbox")
} }
}) })
} else {
log.Info("reject message is already present, ignoring")
}
} }
log.Info("incoming message rejected", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom)) log.Info("incoming message rejected", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom))
metricDelivery.WithLabelValues("reject", a.reason).Inc() metricDelivery.WithLabelValues("reject", a0.reason).Inc()
c.setSlow(true) c.setSlow(true)
addError(rcptAcc, a.code, a.secode, a.userError, a.errmsg) addError(rcpt, a0.code, a0.secode, a0.userError, a0.errmsg)
continue return
} }
delayFirstTime := true 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 // 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) log.Errorx("saving dmarc aggregate report in database", err)
} else { } else {
log.Info("dmarc aggregate report processed") log.Info("dmarc aggregate report processed")
m.Flags.Seen = true a0.d.m.Flags.Seen = true
delayFirstTime = false delayFirstTime = false
} }
} }
if a.tlsReport != nil { if rcpt.account != nil && a0.tlsReport != nil {
// todo future: add rate limiting to prevent DoS attacks. // 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) log.Errorx("saving TLSRPT report in database", err)
} else { } else {
log.Info("tlsrpt report processed") log.Info("tlsrpt report processed")
m.Flags.Seen = true a0.d.m.Flags.Seen = true
delayFirstTime = false 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 // 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 // before actually delivering. If this turns out to be a spammer, we've kept one of
// their connections busy. // 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)) log.Debug("delaying before delivering from sender without reputation", slog.Duration("delay", c.firstTimeSenderDelay))
mox.Sleep(mox.Context, 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 { if Localserve {
code, timeout := mox.LocalserveNeedsError(rcptAcc.rcptTo.Localpart) code, timeout := mox.LocalserveNeedsError(rcpt.addr.Localpart)
if timeout { if timeout {
log.Info("timing out due to special localpart") log.Info("timing out due to special localpart")
mox.Sleep(mox.Context, time.Hour) mox.Sleep(mox.Context, time.Hour)
@ -3049,28 +3071,55 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
} else if code != 0 { } else if code != 0 {
log.Info("failure due to special localpart", slog.Int("code", code)) log.Info("failure due to special localpart", slog.Int("code", code))
metricDelivery.WithLabelValues("delivererror", "localserve").Inc() metricDelivery.WithLabelValues("delivererror", "localserve").Inc()
addError(rcptAcc, code, smtp.SeOther00, false, fmt.Sprintf("failure with code %d due to special localpart", code)) addError(rcpt, code, smtp.SeOther00, false, fmt.Sprintf("failure with code %d due to special localpart", code))
return
} }
} }
var delivered bool
acc.WithWLock(func() { // Gather the message-id before we deliver and the file may be consumed.
if err := acc.DeliverMailbox(log, a.mailbox, &m, dataFile); err != nil { if !parsedMessageID {
log.Errorx("delivering", err) if p, err := message.Parse(c.log.Logger, false, store.FileMsgReader(a0.d.m.MsgPrefix, dataFile)); err != nil {
metricDelivery.WithLabelValues("delivererror", a.reason).Inc() log.Infox("parsing message for message-id", err)
if errors.Is(err, store.ErrOverQuota) { } else if header, err := p.Header(); err != nil {
addError(rcptAcc, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, "account storage full") log.Infox("parsing message header for message-id", err)
} else { } else {
addError(rcptAcc, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing") messageID = header.Get("Message-Id")
}
parsedMessageID = true
}
// Finally deliver the message to the account(s).
var nerr int // Number of non-quota errors.
var nfull int // Number of failed deliveries due to over quota.
var ndelivered int // Number delivered to account.
for _, a := range la {
// Don't deliver to recipient that was explicitly present in SMTP transaction, or
// is sending the message to an alias they are member of.
if rcpt.alias != nil && (regularRecipient(a.d.deliverTo) || a.d.deliverTo.Equal(msgFrom.Path())) {
continue
}
var delivered bool
a.d.acc.WithWLock(func() {
if err := a.d.acc.DeliverMailbox(log, a.mailbox, a.d.m, dataFile); err != nil {
log.Errorx("delivering", err)
metricDelivery.WithLabelValues("delivererror", a0.reason).Inc()
if errors.Is(err, store.ErrOverQuota) {
nfull++
} else {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
nerr++
} }
return return
} }
delivered = true delivered = true
metricDelivery.WithLabelValues("delivered", a.reason).Inc() ndelivered++
log.Info("incoming message delivered", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom)) metricDelivery.WithLabelValues("delivered", a0.reason).Inc()
log.Info("incoming message delivered", slog.String("reason", a0.reason), slog.Any("msgfrom", msgFrom))
conf, _ = acc.Conf() conf, _ := a.d.acc.Conf()
if conf.RejectsMailbox != "" && m.MessageID != "" { if conf.RejectsMailbox != "" && a.d.m.MessageID != "" {
if err := acc.RejectsRemove(log, conf.RejectsMailbox, m.MessageID); err != nil { 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)) log.Errorx("removing message from rejects mailbox", err, slog.String("messageid", messageID))
} }
} }
@ -3078,19 +3127,32 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
// Pass delivered messages to queue for DSN processing and/or hooks. // Pass delivered messages to queue for DSN processing and/or hooks.
if delivered { if delivered {
mr := store.FileMsgReader(m.MsgPrefix, dataFile) mr := store.FileMsgReader(a.d.m.MsgPrefix, dataFile)
part, err := m.LoadPart(mr) part, err := a.d.m.LoadPart(mr)
if err != nil { if err != nil {
log.Errorx("loading parsed part for evaluating webhook", err) log.Errorx("loading parsed part for evaluating webhook", err)
} else { } else {
err = queue.Incoming(context.Background(), log, acc, messageID, m, part, a.mailbox) err = queue.Incoming(context.Background(), log, a.d.acc, messageID, *a.d.m, part, a.mailbox)
log.Check(err, "queueing webhook for incoming delivery") log.Check(err, "queueing webhook for incoming delivery")
} }
} else if nerr > 0 && ndelivered == 0 {
// Don't continue if we had an error and haven't delivered yet. If we only had
// quota-related errors, we keep trying for an account to deliver to.
break
}
}
if ndelivered == 0 && (nerr > 0 || nfull > 0) {
if nerr == 0 {
addError(rcpt, smtp.C452StorageFull, smtp.SeMailbox2Full2, true, "account storage full")
} else {
addError(rcpt, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing")
}
}
} }
err = acc.Close() // For each recipient, do final spam analysis and delivery.
log.Check(err, "closing account after delivering") for _, rcpt := range c.recipients {
acc = nil processRecipient(rcpt)
} }
// If all recipients failed to deliver, return an error. // 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) 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. // 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 // 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. // system full" if the error indicates no disk space is available.
@ -3247,6 +3324,8 @@ func (c *conn) cmdExpn(p *parser) {
} }
p.xend() 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 // ../rfc/5321:4239
xsmtpUserErrorf(smtp.C252WithoutVrfy, smtp.SePol7Other0, "no expand but will try delivery") xsmtpUserErrorf(smtp.C252WithoutVrfy, smtp.SePol7Other0, "no expand but will try delivery")
} }

View file

@ -116,11 +116,13 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir) dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir)
os.RemoveAll(dataDir) os.RemoveAll(dataDir)
var err error var err error
ts.acc, err = store.OpenAccount(log, "mjl") ts.acc, err = store.OpenAccount(log, "mjl")
tcheck(t, err, "open account") tcheck(t, err, "open account")
err = ts.acc.SetPassword(log, password0) err = ts.acc.SetPassword(log, password0)
tcheck(t, err, "set password") tcheck(t, err, "set password")
ts.switchStop = store.Switchboard() ts.switchStop = store.Switchboard()
err = queue.Init() err = queue.Init()
tcheck(t, err, "queue init") tcheck(t, err, "queue init")
@ -143,6 +145,23 @@ func (ts *testserver) close() {
ts.acc = nil 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)) { func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
ts.t.Helper() ts.t.Helper()
ts.runRaw(func(conn net.Conn) { ts.runRaw(func(conn net.Conn) {
@ -194,6 +213,14 @@ func (ts *testserver) runRaw(fn func(clientConn net.Conn)) {
fn(clientConn) 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 // 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 // (that is opportunistic TLS for you, "better some than none"). Let's enjoy this
// one moment where it makes life easier. // one moment where it makes life easier.
@ -508,22 +535,6 @@ func TestSpam(t *testing.T) {
tinsertmsg(t, ts.acc, "Inbox", &nm, deliverMessage) 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. // Delivery from sender with bad reputation should fail.
ts.run(func(err error, client *smtpclient.Client) { ts.run(func(err error, client *smtpclient.Client) {
mailFrom := "remote@example.org" 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) 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. checkEvaluationCount(t, 0) // No positive interactions yet.
}) })
@ -550,8 +561,8 @@ func TestSpam(t *testing.T) {
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
checkCount("mjl2junk", 1) // In ruleset rejects mailbox. ts.checkCount("mjl2junk", 1) // In ruleset rejects mailbox.
checkCount("Rejects", 1) // Same as before. ts.checkCount("Rejects", 1) // Same as before.
checkEvaluationCount(t, 0) // This is not an actual accept. checkEvaluationCount(t, 0) // This is not an actual accept.
}) })
@ -571,8 +582,8 @@ func TestSpam(t *testing.T) {
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
// Message should now be removed from Rejects mailboxes. // Message should now be removed from Rejects mailboxes.
checkCount("Rejects", 0) ts.checkCount("Rejects", 0)
checkCount("mjl2junk", 1) ts.checkCount("mjl2junk", 1)
checkEvaluationCount(t, 1) checkEvaluationCount(t, 1)
}) })

View file

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

View file

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

View file

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

View file

@ -1,5 +1,16 @@
Domains: 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 mox2.example: nil
Accounts: Accounts:
mjl: mjl:
@ -21,3 +32,8 @@ Accounts:
TopWords: 10 TopWords: 10
IgnoreWords: 0.1 IgnoreWords: 0.1
RareWords: 2 RareWords: 2
# not a member of an alias.
☺:
Domain: mox.example
Destinations:
☺@mox.example: nil

View file

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

View file

@ -255,11 +255,11 @@ var api;
// per-outgoing-message address used for sending. // per-outgoing-message address used for sending.
OutgoingEvent["EventUnrecognized"] = "unrecognized"; OutgoingEvent["EventUnrecognized"] = "unrecognized";
})(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {})); })(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.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, "OutgoingEvent": true }; api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true };
api.intsTypes = {}; api.intsTypes = {};
api.types = { 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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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 }, "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": "" }] }, "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 = { api.parser = {
@ -290,6 +295,10 @@ var api;
AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v), AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v),
JunkFilter: (v) => api.parse("JunkFilter", v), JunkFilter: (v) => api.parse("JunkFilter", v),
Route: (v) => api.parse("Route", 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), Suppression: (v) => api.parse("Suppression", v),
ImportProgress: (v) => api.parse("ImportProgress", v), ImportProgress: (v) => api.parse("ImportProgress", v),
Outgoing: (v) => api.parse("Outgoing", v), Outgoing: (v) => api.parse("Outgoing", v),
@ -298,6 +307,7 @@ var api;
Structure: (v) => api.parse("Structure", v), Structure: (v) => api.parse("Structure", v),
IncomingMeta: (v) => api.parse("IncomingMeta", v), IncomingMeta: (v) => api.parse("IncomingMeta", v),
CSRFToken: (v) => api.parse("CSRFToken", v), CSRFToken: (v) => api.parse("CSRFToken", v),
Localpart: (v) => api.parse("Localpart", v),
OutgoingEvent: (v) => api.parse("OutgoingEvent", v), OutgoingEvent: (v) => api.parse("OutgoingEvent", v),
}; };
// Account exports web API functions for the account web interface. All its // 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)); await check(fullNameFieldset, client.AccountSaveFullName(fullName.value));
fullName.setAttribute('value', fullName.value); fullName.setAttribute('value', fullName.value);
fullNameForm.reset(); 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 = ''; 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) { })), ' ', 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(); e.preventDefault();

View file

@ -765,6 +765,40 @@ const index = async () => {
), ),
dom.br(), 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'), dom.h2('Change password'),
passwordForm=dom.form( passwordForm=dom.form(
passwordFieldset=dom.fieldset( passwordFieldset=dom.fieldset(

View file

@ -26,6 +26,8 @@ import (
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
"github.com/mjl-/sherpa" "github.com/mjl-/sherpa"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/queue" "github.com/mjl-/mox/queue"
@ -227,7 +229,20 @@ func TestAccount(t *testing.T) {
err = queue.Init() // For DB. err = queue.Init() // For DB.
tcheck(t, err, "queue init") tcheck(t, err, "queue init")
account, _, _, _ := api.Account(ctx) 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.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 api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed

View file

@ -589,6 +589,14 @@
"Typewords": [ "Typewords": [
"Domain" "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", "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.", "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": "", "Docs": "",
"Values": null "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", "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.", "Docs": "OutgoingEvent is an activity for an outgoing delivery. Either generated by the\nqueue, or through an incoming DSN (delivery status notification) message.",

View file

@ -23,6 +23,7 @@ export interface Account {
NoFirstTimeSenderDelay: boolean NoFirstTimeSenderDelay: boolean
Routes?: Route[] | null Routes?: Route[] | null
DNSDomain: Domain // Parsed form of Domain. DNSDomain: Domain // Parsed form of Domain.
Aliases?: AddressAlias[] | null
} }
export interface OutgoingWebhook { export interface OutgoingWebhook {
@ -96,6 +97,34 @@ export interface Route {
ToDomainASCII?: string[] | null 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 // 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. // deliver or queue will result in an immediate permanent failure to deliver.
export interface Suppression { export interface Suppression {
@ -177,6 +206,12 @@ export interface IncomingMeta {
export type CSRFToken = string 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 // OutgoingEvent is an activity for an outgoing delivery. Either generated by the
// queue, or through an incoming DSN (delivery status notification) message. // queue, or through an incoming DSN (delivery status notification) message.
export enum OutgoingEvent { export enum OutgoingEvent {
@ -203,11 +238,11 @@ export enum OutgoingEvent {
EventUnrecognized = "unrecognized", 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 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,"OutgoingEvent":true} export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true}
export const intsTypes: {[typename: string]: boolean} = {} export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = { 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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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}, "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":""}]}, "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, AutomaticJunkFlags: (v: any) => parse("AutomaticJunkFlags", v) as AutomaticJunkFlags,
JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter, JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter,
Route: (v: any) => parse("Route", v) as Route, 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, Suppression: (v: any) => parse("Suppression", v) as Suppression,
ImportProgress: (v: any) => parse("ImportProgress", v) as ImportProgress, ImportProgress: (v: any) => parse("ImportProgress", v) as ImportProgress,
Outgoing: (v: any) => parse("Outgoing", v) as Outgoing, Outgoing: (v: any) => parse("Outgoing", v) as Outgoing,
@ -247,6 +291,7 @@ export const parser = {
Structure: (v: any) => parse("Structure", v) as Structure, Structure: (v: any) => parse("Structure", v) as Structure,
IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta, IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
Localpart: (v: any) => parse("Localpart", v) as Localpart,
OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent, OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent,
} }

View file

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

View file

@ -337,7 +337,7 @@ var api;
SPFResult["SPFTemperror"] = "temperror"; SPFResult["SPFTemperror"] = "temperror";
SPFResult["SPFPermerror"] = "permerror"; SPFResult["SPFPermerror"] = "permerror";
})(SPFResult = api.SPFResult || (api.SPFResult = {})); })(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.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.intsTypes = {};
api.types = { 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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] },
"OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] }, "AliasAddress": { "Name": "AliasAddress", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["Address"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destination", "Docs": "", "Typewords": ["Destination"] }] },
"IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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), MTASTS: (v) => api.parse("MTASTS", v),
TLSRPT: (v) => api.parse("TLSRPT", v), TLSRPT: (v) => api.parse("TLSRPT", v),
Route: (v) => api.parse("Route", 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), Account: (v) => api.parse("Account", v),
OutgoingWebhook: (v) => api.parse("OutgoingWebhook", v), OutgoingWebhook: (v) => api.parse("OutgoingWebhook", v),
IncomingWebhook: (v) => api.parse("IncomingWebhook", 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), SubjectPass: (v) => api.parse("SubjectPass", v),
AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v), AutomaticJunkFlags: (v) => api.parse("AutomaticJunkFlags", v),
JunkFilter: (v) => api.parse("JunkFilter", v), JunkFilter: (v) => api.parse("JunkFilter", v),
AddressAlias: (v) => api.parse("AddressAlias", v),
PolicyRecord: (v) => api.parse("PolicyRecord", v), PolicyRecord: (v) => api.parse("PolicyRecord", v),
TLSReportRecord: (v) => api.parse("TLSReportRecord", v), TLSReportRecord: (v) => api.parse("TLSReportRecord", v),
Report: (v) => api.parse("Report", v), Report: (v) => api.parse("Report", v),
@ -680,7 +688,7 @@ var api;
async DomainLocalparts(domain) { async DomainLocalparts(domain) {
const fn = "DomainLocalparts"; const fn = "DomainLocalparts";
const paramTypes = [["string"]]; const paramTypes = [["string"]];
const returnTypes = [["{}", "string"]]; const returnTypes = [["{}", "string"], ["{}", "Alias"]];
const params = [domain]; const params = [domain];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
@ -1351,6 +1359,41 @@ var api;
const params = [domainName, selectors, sign]; const params = [domainName, selectors, sign];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); 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.Client = Client;
api.defaultBaseURL = (function () { api.defaultBaseURL = (function () {
@ -2221,7 +2264,10 @@ const account = async (name) => {
await check(fieldset, client.AddressAdd(address, name)); await check(fieldset, client.AddressAdd(address, name));
form.reset(); form.reset();
window.location.reload(); // todo: only reload the destinations 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.stopPropagation();
e.preventDefault(); e.preventDefault();
await check(fieldsetSettings, client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked)); 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 domain = async (d) => {
const end = new Date(); const end = new Date();
const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); 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.DMARCSummaries(start, end, d),
client.TLSRPTSummaries(start, end, d), client.TLSRPTSummaries(start, end, d),
client.DomainLocalparts(d), client.DomainLocalparts(d),
@ -2326,6 +2372,9 @@ const domain = async (d) => {
let addrFieldset; let addrFieldset;
let addrLocalpart; let addrLocalpart;
let addrAccount; let addrAccount;
let aliasFieldset;
let aliasLocalpart;
let aliasAddresses;
let descrFieldset; let descrFieldset;
let descrText; let descrText;
let clientSettingsDomainFieldset; let clientSettingsDomainFieldset;
@ -2401,7 +2450,23 @@ const domain = async (d) => {
await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value)); await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value));
addrForm.reset(); addrForm.reset();
window.location.reload(); // todo: only reload the addresses 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value)); await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value));
@ -2571,6 +2636,44 @@ const domain = async (d) => {
window.location.hash = '#'; 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 domainDNSRecords = async (d) => {
const [records, dnsdomain] = await Promise.all([ const [records, dnsdomain] = await Promise.all([
client.DomainRecords(d), client.DomainRecords(d),
@ -4011,6 +4114,9 @@ const init = async () => {
else if (t[0] === 'domains' && t.length === 2) { else if (t[0] === 'domains' && t.length === 2) {
await domain(t[1]); 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') { else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') {
await domainDMARC(t[1]); await domainDMARC(t[1]);
} }

View file

@ -875,6 +875,37 @@ const account = async (name: string) => {
), ),
), ),
dom.br(), 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.h2('Settings'),
dom.form( dom.form(
fieldsetSettings=dom.fieldset( fieldsetSettings=dom.fieldset(
@ -1009,7 +1040,7 @@ const formatDuration = (v: number, goDuration?: boolean) => {
const domain = async (d: string) => { const domain = async (d: string) => {
const end = new Date() const end = new Date()
const start = new Date(new Date().getTime() - 30*24*3600*1000) 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.DMARCSummaries(start, end, d),
client.TLSRPTSummaries(start, end, d), client.TLSRPTSummaries(start, end, d),
client.DomainLocalparts(d), client.DomainLocalparts(d),
@ -1025,6 +1056,10 @@ const domain = async (d: string) => {
let addrLocalpart: HTMLInputElement let addrLocalpart: HTMLInputElement
let addrAccount: HTMLSelectElement let addrAccount: HTMLSelectElement
let aliasFieldset: HTMLFieldSetElement
let aliasLocalpart: HTMLInputElement
let aliasAddresses: HTMLTextAreaElement
let descrFieldset: HTMLFieldSetElement let descrFieldset: HTMLFieldSetElement
let descrText: HTMLInputElement let descrText: HTMLInputElement
@ -1247,7 +1282,6 @@ const domain = async (d: string) => {
), ),
), ),
dom.br(), dom.br(),
dom.h2('Add address'), dom.h2('Add address'),
addrForm=dom.form( addrForm=dom.form(
async function submit(e: SubmitEvent) { async function submit(e: SubmitEvent) {
@ -1278,6 +1312,64 @@ const domain = async (d: string) => {
), ),
dom.br(), 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)), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes: api.Route[]) => await client.DomainRoutesSave(d, routes)),
dom.br(), 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 domainDNSRecords = async (d: string) => {
const [records, dnsdomain] = await Promise.all([ const [records, dnsdomain] = await Promise.all([
client.DomainRecords(d), client.DomainRecords(d),
@ -4846,6 +5054,8 @@ const init = async () => {
await account(t[1]) await account(t[1])
} else if (t[0] === 'domains' && t.length === 2) { } else if (t[0] === 'domains' && t.length === 2) {
await domain(t[1]) 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') { } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') {
await domainDMARC(t[1]) await domainDMARC(t[1])
} else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) {

View file

@ -321,6 +321,44 @@ func TestAdmin(t *testing.T) {
api.DomainDKIMRemove(ctxbg, "mox.example", "testsel") 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, "mox.example", "testsel") }) // Already removed.
tneedErrorCode(t, "user:error", func() { api.DomainDKIMRemove(ctxbg, "bogus.example", "testsel") }) 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) { func TestCheckDomain(t *testing.T) {

View file

@ -159,6 +159,13 @@
"{}", "{}",
"string" "string"
] ]
},
{
"Name": "localpartAliases",
"Typewords": [
"{}",
"Alias"
]
} }
] ]
}, },
@ -1898,6 +1905,139 @@
} }
], ],
"Returns": [] "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": [], "Sections": [],
@ -3234,6 +3374,21 @@
"[]", "[]",
"Route" "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", "Name": "Account",
"Docs": "", "Docs": "",
@ -3682,6 +4053,14 @@
"Typewords": [ "Typewords": [
"Domain" "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", "Name": "SubjectPass",
"Docs": "", "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", "Name": "PolicyRecord",
"Docs": "PolicyRecord is a cached policy or absence of a policy.", "Docs": "PolicyRecord is a cached policy or absence of a policy.",

View file

@ -275,6 +275,8 @@ export interface ConfigDomain {
MTASTS?: MTASTS | null MTASTS?: MTASTS | null
TLSRPT?: TLSRPT | null TLSRPT?: TLSRPT | null
Routes?: Route[] | null Routes?: Route[] | null
Aliases?: { [key: string]: Alias }
Domain: Domain
} }
export interface DKIM { export interface DKIM {
@ -333,38 +335,26 @@ export interface Route {
ToDomainASCII?: string[] | null ToDomainASCII?: string[] | null
} }
export interface Account { export interface Alias {
OutgoingWebhook?: OutgoingWebhook | null Addresses?: string[] | null
IncomingWebhook?: IncomingWebhook | null PostPublic: boolean
FromIDLoginAddresses?: string[] | null ListMembers: boolean
KeepRetiredMessagePeriod: number AllowMsgFrom: boolean
KeepRetiredWebhookPeriod: number LocalpartStr: string // In encoded form.
Domain: string Domain: Domain
Description: string ParsedAddresses?: AliasAddress[] | null // Matches addresses.
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 OutgoingWebhook { export interface AliasAddress {
URL: string Address: Address // Parsed address.
Authorization: string AccountName: string // Looked up.
Events?: string[] | null Destination: Destination // Belonging to address.
} }
export interface IncomingWebhook { // Address is a parsed email address.
URL: string export interface Address {
Authorization: string Localpart: Localpart
Domain: Domain // todo: shouldn't we accept an ip address here too? and merge this type into smtp.Path.
} }
export interface Destination { export interface Destination {
@ -387,6 +377,41 @@ export interface Ruleset {
ListAllowDNSDomain: Domain 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 { export interface SubjectPass {
Period: number // todo: have a reasonable default for this? Period: number // todo: have a reasonable default for this?
} }
@ -409,6 +434,12 @@ export interface JunkFilter {
RareWords: number 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. // PolicyRecord is a cached policy or absence of a policy.
export interface PolicyRecord { export interface PolicyRecord {
Domain: string // Domain name, with unicode characters. Domain: string // Domain name, with unicode characters.
@ -1146,7 +1177,7 @@ export enum SPFResult {
// be an IPv4 address. // be an IPv4 address.
export type IP = string 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 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 intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = { 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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]},
"OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]}, "AliasAddress": {"Name":"AliasAddress","Docs":"","Fields":[{"Name":"Address","Docs":"","Typewords":["Address"]},{"Name":"AccountName","Docs":"","Typewords":["string"]},{"Name":"Destination","Docs":"","Typewords":["Destination"]}]},
"IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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, MTASTS: (v: any) => parse("MTASTS", v) as MTASTS,
TLSRPT: (v: any) => parse("TLSRPT", v) as TLSRPT, TLSRPT: (v: any) => parse("TLSRPT", v) as TLSRPT,
Route: (v: any) => parse("Route", v) as Route, 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, Account: (v: any) => parse("Account", v) as Account,
OutgoingWebhook: (v: any) => parse("OutgoingWebhook", v) as OutgoingWebhook, OutgoingWebhook: (v: any) => parse("OutgoingWebhook", v) as OutgoingWebhook,
IncomingWebhook: (v: any) => parse("IncomingWebhook", v) as IncomingWebhook, 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, SubjectPass: (v: any) => parse("SubjectPass", v) as SubjectPass,
AutomaticJunkFlags: (v: any) => parse("AutomaticJunkFlags", v) as AutomaticJunkFlags, AutomaticJunkFlags: (v: any) => parse("AutomaticJunkFlags", v) as AutomaticJunkFlags,
JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter, JunkFilter: (v: any) => parse("JunkFilter", v) as JunkFilter,
AddressAlias: (v: any) => parse("AddressAlias", v) as AddressAlias,
PolicyRecord: (v: any) => parse("PolicyRecord", v) as PolicyRecord, PolicyRecord: (v: any) => parse("PolicyRecord", v) as PolicyRecord,
TLSReportRecord: (v: any) => parse("TLSReportRecord", v) as TLSReportRecord, TLSReportRecord: (v: any) => parse("TLSReportRecord", v) as TLSReportRecord,
Report: (v: any) => parse("Report", v) as Report, 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. // 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 fn: string = "DomainLocalparts"
const paramTypes: string[][] = [["string"]] const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["{}","string"]] const returnTypes: string[][] = [["{}","string"],["{}","Alias"]]
const params: any[] = [domain] 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. // Accounts returns the names of all configured accounts.
@ -2253,6 +2292,46 @@ export class Client {
const params: any[] = [domainName, selectors, sign] const params: any[] = [domainName, selectors, sign]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
} }
async AliasAdd(aliaslp: string, domainName: string, alias: Alias): Promise<void> {
const fn: string = "AliasAdd"
const paramTypes: string[][] = [["string"],["string"],["Alias"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, alias]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasUpdate(aliaslp: string, domainName: string, postPublic: boolean, listMembers: boolean, allowMsgFrom: boolean): Promise<void> {
const fn: string = "AliasUpdate"
const paramTypes: string[][] = [["string"],["string"],["bool"],["bool"],["bool"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, postPublic, listMembers, allowMsgFrom]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasRemove(aliaslp: string, domainName: string): Promise<void> {
const fn: string = "AliasRemove"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasAddressesAdd(aliaslp: string, domainName: string, addresses: string[] | null): Promise<void> {
const fn: string = "AliasAddressesAdd"
const paramTypes: string[][] = [["string"],["string"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, addresses]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
async AliasAddressesRemove(aliaslp: string, domainName: string, addresses: string[] | null): Promise<void> {
const fn: string = "AliasAddressesRemove"
const paramTypes: string[][] = [["string"],["string"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [aliaslp, domainName, addresses]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
} }
export const defaultBaseURL = (function() { export const defaultBaseURL = (function() {

View file

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

View file

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

View file

@ -618,7 +618,7 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R
accConf, _ := acc.Conf() accConf, _ := acc.Conf()
loginAddr, err := smtp.ParseAddress(address) loginAddr, err := smtp.ParseAddress(address)
xcheckf(ctx, err, "parsing login 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") xcheckf(ctx, err, "looking up destination for login address")
loginName := accConf.FullName loginName := accConf.FullName
if dest.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) 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 // We implicitly start a query. We use the reqctx for the transaction, because the
// transaction is passed to the query, which can be canceled. // transaction is passed to the query, which can be canceled.

View file

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