webadmin: make remaining domain settings configurable via admin web interface

for dmarc reporting address, tls reporting address, mtasts policy, dkim keys/selectors.

should make it easier for webadmin-using admins to discover these settings.

the webadmin interface is now on par with functionality you would set through
the configuration file, let's keep it that way.
This commit is contained in:
Mechiel Lukkien 2024-04-19 10:23:53 +02:00
parent a69887bfab
commit e702f45d32
No known key found for this signature in database
11 changed files with 1836 additions and 100 deletions

View file

@ -107,7 +107,7 @@ fmt:
go fmt ./... go fmt ./...
gofmt -w -s *.go */*.go gofmt -w -s *.go */*.go
jswatch: tswatch:
bash -c 'while true; do inotifywait -q -e close_write *.ts webadmin/*.ts webaccount/*.ts webmail/*.ts; make frontend; done' bash -c 'while true; do inotifywait -q -e close_write *.ts webadmin/*.ts webaccount/*.ts webmail/*.ts; make frontend; done'
install-js: install-js:

View file

@ -279,7 +279,7 @@ type Domain struct {
LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."`
DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."`
DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."`
MTASTS *MTASTS `sconf:"optional" sconf-doc:"With MTA-STS a domain publishes, in DNS, presence of a policy for using/requiring TLS for SMTP connections. The policy is served over HTTPS."` 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."`
@ -294,7 +294,7 @@ type Domain struct {
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 report recipient address. Can be used to receive reports for other domains. 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."`
Account string `sconf-doc:"Account to deliver to."` Account string `sconf-doc:"Account to deliver to."`
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. DMARC."` Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. DMARC."`
@ -303,8 +303,8 @@ type DMARC struct {
} }
type MTASTS struct { type MTASTS struct {
PolicyID string `sconf-doc:"Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it in mox, then update the DNS record."` PolicyID string `sconf-doc:"Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it here to update the served policy, then update the DNS record with the updated policy ID."`
Mode mtasts.Mode `sconf-doc:"testing, enforce or none. If set to enforce, a remote SMTP server will not deliver email to us if it cannot make a TLS connection."` Mode mtasts.Mode `sconf-doc:"If set to \"enforce\", a remote SMTP server will not deliver email to us if it cannot make a WebPKI-verified SMTP STARTTLS connection. In mode \"testing\", deliveries can be done without verified TLS, but errors will be reported through TLS reporting. In mode \"none\", verified TLS is not required, used for phasing out an MTA-STS policy."`
MaxAge time.Duration `sconf-doc:"How long a remote mail server is allowed to cache a policy. Typically 1 or several weeks."` MaxAge time.Duration `sconf-doc:"How long a remote mail server is allowed to cache a policy. Typically 1 or several weeks."`
MX []string `sconf:"optional" sconf-doc:"List of server names allowed for SMTP. If empty, the configured hostname is set. Host names can contain a wildcard (*) as a leading label (matching a single label, e.g. *.example matches host.example, not sub.host.example)."` MX []string `sconf:"optional" sconf-doc:"List of server names allowed for SMTP. If empty, the configured hostname is set. Host names can contain a wildcard (*) as a leading label (matching a single label, e.g. *.example matches host.example, not sub.host.example)."`
// todo: parse mx as valid mtasts.Policy.MX, with dns.ParseDomain but taking wildcard into account // todo: parse mx as valid mtasts.Policy.MX, with dns.ParseDomain but taking wildcard into account
@ -312,7 +312,7 @@ type MTASTS struct {
type TLSRPT struct { type TLSRPT struct {
Localpart string `sconf-doc:"Address-part before the @ that accepts TLSRPT reports. Recommended value: tls-reports."` Localpart string `sconf-doc:"Address-part before the @ that accepts TLSRPT reports. Recommended value: tls-reports."`
Domain string `sconf:"optional" sconf-doc:"Alternative domain for report recipient address. Can be used to receive reports for other domains. 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 TLSRPT 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."`
Account string `sconf-doc:"Account to deliver to."` Account string `sconf-doc:"Account to deliver to."`
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. TLSRPT."` Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. TLSRPT."`
@ -326,7 +326,7 @@ type Canonicalization struct {
} }
type Selector struct { type Selector struct {
Hash string `sconf:"optional" sconf-doc:"sha256 (default) or (older, not recommended) sha1"` Hash string `sconf:"optional" sconf-doc:"sha256 (default) or (older, not recommended) sha1."`
HashEffective string `sconf:"-"` HashEffective string `sconf:"-"`
Canonicalization Canonicalization `sconf:"optional"` Canonicalization Canonicalization `sconf:"optional"`
Headers []string `sconf:"optional" sconf-doc:"Headers to sign with DKIM. If empty, a reasonable default set of headers is selected."` Headers []string `sconf:"optional" sconf-doc:"Headers to sign with DKIM. If empty, a reasonable default set of headers is selected."`
@ -335,6 +335,7 @@ type Selector struct {
Expiration string `sconf:"optional" sconf-doc:"Period a signature is valid after signing, as duration, e.g. 72h. The period should be enough for delivery at the final destination, potentially with several hops/relays. In the order of days at least."` Expiration string `sconf:"optional" sconf-doc:"Period a signature is valid after signing, as duration, e.g. 72h. The period should be enough for delivery at the final destination, potentially with several hops/relays. In the order of days at least."`
PrivateKeyFile string `sconf-doc:"Either an RSA or ed25519 private key file in PKCS8 PEM form."` PrivateKeyFile string `sconf-doc:"Either an RSA or ed25519 private key file in PKCS8 PEM form."`
Algorithm string `sconf:"-"` // "ed25519", "rsa-*", based on private key.
ExpirationSeconds int `sconf:"-" json:"-"` // Parsed from Expiration. ExpirationSeconds int `sconf:"-" json:"-"` // Parsed from Expiration.
Key crypto.Signer `sconf:"-" json:"-"` // As parsed with x509.ParsePKCS8PrivateKey. Key crypto.Signer `sconf:"-" json:"-"` // As parsed with x509.ParsePKCS8PrivateKey.
Domain dns.Domain `sconf:"-" json:"-"` // Of selector only, not FQDN. Domain dns.Domain `sconf:"-" json:"-"` // Of selector only, not FQDN.

View file

@ -752,7 +752,7 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
Selectors: Selectors:
x: x:
# sha256 (default) or (older, not recommended) sha1 (optional) # sha256 (default) or (older, not recommended) sha1. (optional)
Hash: Hash:
# (optional) # (optional)
@ -800,8 +800,15 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# non-internationalized. Recommended value: dmarc-reports. # non-internationalized. Recommended value: dmarc-reports.
Localpart: Localpart:
# Alternative domain for report recipient address. Can be used to receive reports # Alternative domain for reporting address, for incoming reports. Typically empty,
# for other domains. Unicode name. (optional) # 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. (optional)
Domain: Domain:
# Account to deliver to. # Account to deliver to.
@ -810,17 +817,35 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# Mailbox to deliver to, e.g. DMARC. # Mailbox to deliver to, e.g. DMARC.
Mailbox: Mailbox:
# With MTA-STS a domain publishes, in DNS, presence of a policy for # MTA-STS is a mechanism that allows publishing a policy with requirements for
# using/requiring TLS for SMTP connections. The policy is served over HTTPS. # WebPKI-verified SMTP STARTTLS connections for email delivered to a domain.
# (optional) # 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. (optional)
MTASTS: MTASTS:
# Policies are versioned. The version must be specified in the DNS record. If you # Policies are versioned. The version must be specified in the DNS record. If you
# change a policy, first change it in mox, then update the DNS record. # change a policy, first change it here to update the served policy, then update
# the DNS record with the updated policy ID.
PolicyID: PolicyID:
# testing, enforce or none. If set to enforce, a remote SMTP server will not # If set to "enforce", a remote SMTP server will not deliver email to us if it
# deliver email to us if it cannot make a TLS connection. # cannot make a WebPKI-verified SMTP STARTTLS connection. In mode "testing",
# deliveries can be done without verified TLS, but errors will be reported through
# TLS reporting. In mode "none", verified TLS is not required, used for phasing
# out an MTA-STS policy.
Mode: Mode:
# How long a remote mail server is allowed to cache a policy. Typically 1 or # How long a remote mail server is allowed to cache a policy. Typically 1 or
@ -843,8 +868,15 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# tls-reports. # tls-reports.
Localpart: Localpart:
# Alternative domain for report recipient address. Can be used to receive reports # Alternative domain for reporting address, for incoming reports. Typically empty,
# for other domains. Unicode name. (optional) # 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 TLSRPT
# 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. (optional)
Domain: Domain:
# Account to deliver to. # Account to deliver to.

View file

@ -10,6 +10,7 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@ -29,11 +30,14 @@ import (
"github.com/mjl-/mox/dmarc" "github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk" "github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/tlsrpt" "github.com/mjl-/mox/tlsrpt"
) )
var ErrRequest = errors.New("bad request")
// TXTStrings returns a TXT record value as one or more quoted strings, each max // TXTStrings returns a TXT record value as one or more quoted strings, each max
// 100 characters. In case of multiple strings, a multi-line record is returned. // 100 characters. In case of multiple strings, a multi-line record is returned.
func TXTStrings(s string) string { func TXTStrings(s string) string {
@ -151,24 +155,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account {
return account return account
} }
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using func writeFile(log mlog.Log, path string, data []byte) error {
// accountName for DMARC and TLS reports.
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
log := pkglog.WithContext(ctx)
now := time.Now()
year := now.Format("2006")
timestamp := now.Format("20060102T150405")
var paths []string
defer func() {
for _, p := range paths {
err := os.Remove(p)
log.Check(err, "removing path for domain config", slog.String("path", p))
}
}()
writeFile := func(path string, data []byte) error {
os.MkdirAll(filepath.Dir(path), 0770) os.MkdirAll(filepath.Dir(path), 0770)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660) f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
@ -193,6 +180,23 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
return nil return nil
} }
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
// accountName for DMARC and TLS reports.
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
log := pkglog.WithContext(ctx)
now := time.Now()
year := now.Format("2006")
timestamp := now.Format("20060102T150405")
var paths []string
defer func() {
for _, p := range paths {
err := os.Remove(p)
log.Check(err, "removing path for domain config", slog.String("path", p))
}
}()
confDKIM := config.DKIM{ confDKIM := config.DKIM{
Selectors: map[string]config.Selector{}, Selectors: map[string]config.Selector{},
} }
@ -201,7 +205,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII) record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath) p := configDirPath(ConfigDynamicPath, keyPath)
if err := writeFile(p, privKey); err != nil { if err := writeFile(log, p, privKey); err != nil {
return err return err
} }
paths = append(paths, p) paths = append(paths, p)
@ -282,6 +286,164 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
return confDomain, rpaths, nil return confDomain, rpaths, nil
} }
// DKIMAdd adds a DKIM selector for a domain, generating a key and writing it to disk.
func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) (rerr error) {
log := pkglog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("adding dkim key", rerr,
slog.Any("domain", domain),
slog.Any("selector", selector))
}
}()
switch hash {
case "sha256", "sha1":
default:
return fmt.Errorf("%w: unknown hash algorithm %q", ErrRequest, hash)
}
var privKey []byte
var err error
var kind string
switch algorithm {
case "rsa":
privKey, err = MakeDKIMRSAKey(selector, domain)
kind = "rsa2048"
case "ed25519":
privKey, err = MakeDKIMEd25519Key(selector, domain)
kind = "ed25519"
default:
err = fmt.Errorf("unknown algorithm")
}
if err != nil {
return fmt.Errorf("%w: making dkim key: %v", ErrRequest, err)
}
// Only take lock now, we don't want to hold it while generating a key.
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
}
if _, ok := d.DKIM.Selectors[selector.Name()]; ok {
return fmt.Errorf("%w: selector already exists for domain", ErrRequest)
}
record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
timestamp := time.Now().Format("20060102T150405")
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath)
if err := writeFile(log, p, privKey); err != nil {
return fmt.Errorf("writing key file: %v", err)
}
removePath := p
defer func() {
if removePath != "" {
err := os.Remove(removePath)
log.Check(err, "removing path for dkim key", slog.String("path", removePath))
}
}()
nsel := config.Selector{
Hash: hash,
Canonicalization: config.Canonicalization{
HeaderRelaxed: headerRelaxed,
BodyRelaxed: bodyRelaxed,
},
Headers: headers,
DontSealHeaders: !seal,
Expiration: lifetime.String(),
PrivateKeyFile: keyPath,
}
// All good, time to update the config.
nd := d
nd.DKIM.Selectors = map[string]config.Selector{}
for name, osel := range d.DKIM.Selectors {
nd.DKIM.Selectors[name] = osel
}
nd.DKIM.Selectors[selector.Name()] = nsel
nc := c
nc.Domains = map[string]config.Domain{}
for name, dom := range c.Domains {
nc.Domains[name] = dom
}
nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("dkim key added", slog.Any("domain", domain), slog.Any("selector", selector))
removePath = "" // Prevent cleanup of key file.
return nil
}
// DKIMRemove removes the selector from the domain, moving the key file out of the way.
func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
log := pkglog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("removing dkim key", rerr,
slog.Any("domain", domain),
slog.Any("selector", selector))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
}
sel, ok := d.DKIM.Selectors[selector.Name()]
if !ok {
return fmt.Errorf("%w: selector does not exist for domain", ErrRequest)
}
nsels := map[string]config.Selector{}
for name, sel := range d.DKIM.Selectors {
if name != selector.Name() {
nsels[name] = sel
}
}
nsign := make([]string, 0, len(d.DKIM.Sign))
for _, name := range d.DKIM.Sign {
if name != selector.Name() {
nsign = append(nsign, name)
}
}
nd := d
nd.DKIM = config.DKIM{Selectors: nsels, Sign: nsign}
nc := c
nc.Domains = map[string]config.Domain{}
for name, dom := range c.Domains {
nc.Domains[name] = dom
}
nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
// Move away a DKIM private key to a subdirectory "old". But only if
// not in use by other domains.
usedKeyPaths := gatherUsedKeysPaths(nc)
moveAwayKeys(log, map[string]config.Selector{selector.Name(): sel}, usedKeyPaths)
log.Info("dkim key removed", slog.Any("domain", domain), slog.Any("selector", selector))
return nil
}
// DomainAdd adds the domain to the domains config, rewriting domains.conf and // DomainAdd adds the domain to the domains config, rewriting domains.conf and
// marking it loaded. // marking it loaded.
// //
@ -304,7 +466,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
c := Conf.Dynamic c := Conf.Dynamic
if _, ok := c.Domains[domain.Name()]; ok { if _, ok := c.Domains[domain.Name()]; ok {
return fmt.Errorf("domain already present") return fmt.Errorf("%w: domain already present", ErrRequest)
} }
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
@ -336,11 +498,11 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
}() }()
if _, ok := c.Accounts[accountName]; ok && localpart != "" { if _, ok := c.Accounts[accountName]; ok && localpart != "" {
return fmt.Errorf("account already exists (leave localpart empty when using an existing account)") return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest)
} else if !ok && localpart == "" { } else if !ok && localpart == "" {
return fmt.Errorf("account does not yet exist (specify a localpart)") return fmt.Errorf("%w: account does not yet exist (specify a localpart)", ErrRequest)
} else if accountName == "" { } else if accountName == "" {
return fmt.Errorf("account name is empty") return fmt.Errorf("%w: account name is empty", ErrRequest)
} else if !ok { } else if !ok {
nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain}) nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain})
} else if accountName != Conf.Static.Postmaster.Account { } else if accountName != Conf.Static.Postmaster.Account {
@ -358,7 +520,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
nc.Domains[domain.Name()] = confDomain nc.Domains[domain.Name()] = confDomain
if err := writeDynamic(ctx, log, nc); err != nil { if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("domain added", slog.Any("domain", domain)) log.Info("domain added", slog.Any("domain", domain))
cleanupFiles = nil // All good, don't cleanup. cleanupFiles = nil // All good, don't cleanup.
@ -382,7 +544,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
c := Conf.Dynamic c := Conf.Dynamic
domConf, ok := c.Domains[domain.Name()] domConf, ok := c.Domains[domain.Name()]
if !ok { if !ok {
return fmt.Errorf("domain does not exist") return fmt.Errorf("%w: domain does not exist", ErrRequest)
} }
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
@ -397,18 +559,30 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
} }
if err := writeDynamic(ctx, log, nc); err != nil { if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
// Move away any DKIM private keys to a subdirectory "old". But only if // Move away any DKIM private keys to a subdirectory "old". But only if
// they are not in use by other domains. // they are not in use by other domains.
usedKeyPaths := gatherUsedKeysPaths(nc)
moveAwayKeys(log, domConf.DKIM.Selectors, usedKeyPaths)
log.Info("domain removed", slog.Any("domain", domain))
return nil
}
func gatherUsedKeysPaths(nc config.Dynamic) map[string]bool {
usedKeyPaths := map[string]bool{} usedKeyPaths := map[string]bool{}
for _, dc := range nc.Domains { for _, dc := range nc.Domains {
for _, sel := range dc.DKIM.Selectors { for _, sel := range dc.DKIM.Selectors {
usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
} }
} }
for _, sel := range domConf.DKIM.Selectors { return usedKeyPaths
}
func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths map[string]bool) {
for _, sel := range sels {
if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] { if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
continue continue
} }
@ -425,9 +599,6 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst)) log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst))
} }
} }
log.Info("domain removed", slog.Any("domain", domain))
return nil
} }
// DomainSave calls xmodify with a shallow copy of the domain config. xmodify // DomainSave calls xmodify with a shallow copy of the domain config. xmodify
@ -448,7 +619,7 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
nc := Conf.Dynamic // Shallow copy. nc := Conf.Dynamic // Shallow copy.
dom, ok := nc.Domains[domainName] // dom is a shallow copy. dom, ok := nc.Domains[domainName] // dom is a shallow copy.
if !ok { if !ok {
return fmt.Errorf("domain not present") return fmt.Errorf("%w: domain not present", ErrRequest)
} }
xmodify(&dom) xmodify(&dom)
@ -789,7 +960,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
addr, err := smtp.ParseAddress(address) addr, err := smtp.ParseAddress(address)
if err != nil { if err != nil {
return fmt.Errorf("parsing email address: %v", err) return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
} }
Conf.dynamicMutex.Lock() Conf.dynamicMutex.Lock()
@ -797,11 +968,11 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
c := Conf.Dynamic c := Conf.Dynamic
if _, ok := c.Accounts[account]; ok { if _, ok := c.Accounts[account]; ok {
return fmt.Errorf("account already present") return fmt.Errorf("%w: account already present", ErrRequest)
} }
if err := checkAddressAvailable(addr); err != nil { if err := checkAddressAvailable(addr); err != nil {
return fmt.Errorf("address not available: %v", err) return fmt.Errorf("%w: address not available: %v", ErrRequest, 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
@ -834,7 +1005,7 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
c := Conf.Dynamic c := Conf.Dynamic
if _, ok := c.Accounts[account]; !ok { if _, ok := c.Accounts[account]; !ok {
return fmt.Errorf("account does not exist") return fmt.Errorf("%w: account does not exist", ErrRequest)
} }
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
@ -888,30 +1059,30 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
c := Conf.Dynamic c := Conf.Dynamic
a, ok := c.Accounts[account] a, ok := c.Accounts[account]
if !ok { if !ok {
return fmt.Errorf("account does not exist") return fmt.Errorf("%w: account does not exist", ErrRequest)
} }
var destAddr string var destAddr string
if strings.HasPrefix(address, "@") { if strings.HasPrefix(address, "@") {
d, err := dns.ParseDomain(address[1:]) d, err := dns.ParseDomain(address[1:])
if err != nil { if err != nil {
return fmt.Errorf("parsing domain: %v", err) return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err)
} }
dname := d.Name() dname := d.Name()
destAddr = "@" + dname destAddr = "@" + dname
if _, ok := Conf.Dynamic.Domains[dname]; !ok { if _, ok := Conf.Dynamic.Domains[dname]; !ok {
return fmt.Errorf("domain does not exist") return fmt.Errorf("%w: domain does not exist", ErrRequest)
} else if _, ok := Conf.accountDestinations[destAddr]; ok { } else if _, ok := Conf.accountDestinations[destAddr]; ok {
return fmt.Errorf("catchall address already configured for domain") return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
} }
} else { } else {
addr, err := smtp.ParseAddress(address) addr, err := smtp.ParseAddress(address)
if err != nil { if err != nil {
return fmt.Errorf("parsing email address: %v", err) return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
} }
if err := checkAddressAvailable(addr); err != nil { if err := checkAddressAvailable(addr); err != nil {
return fmt.Errorf("address not available: %v", err) return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
} }
destAddr = addr.String() destAddr = addr.String()
} }
@ -953,7 +1124,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
ad, ok := Conf.accountDestinations[address] ad, ok := Conf.accountDestinations[address]
if !ok { if !ok {
return fmt.Errorf("address does not exists") return fmt.Errorf("%w: address does not exists", ErrRequest)
} }
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
@ -973,7 +1144,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
} }
} }
if !dropped { if !dropped {
return fmt.Errorf("address not removed, likely a postmaster/reporting address") return fmt.Errorf("%w: address not removed, likely a postmaster/reporting address", ErrRequest)
} }
// Also remove matching address from FromIDLoginAddresses, composing a new slice. // Also remove matching address from FromIDLoginAddresses, composing a new slice.
@ -984,12 +1155,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if strings.HasPrefix(address, "@") { if strings.HasPrefix(address, "@") {
dom, err = dns.ParseDomain(address[1:]) dom, err = dns.ParseDomain(address[1:])
if err != nil { if err != nil {
return fmt.Errorf("parsing domain for catchall address: %v", err) return fmt.Errorf("%w: parsing domain for catchall address: %v", ErrRequest, err)
} }
} else { } else {
pa, err = smtp.ParseAddress(address) pa, err = smtp.ParseAddress(address)
if err != nil { if err != nil {
return fmt.Errorf("parsing address: %v", err) return fmt.Errorf("%w: parsing address: %v", ErrRequest, err)
} }
dom = pa.Domain dom = pa.Domain
} }
@ -1004,15 +1175,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
} }
dc, ok := Conf.Dynamic.Domains[dom.Name()] dc, ok := Conf.Dynamic.Domains[dom.Name()]
if !ok { if !ok {
return fmt.Errorf("unknown domain in fromid login address %q", 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, err := CanonicalLocalpart(fa.Localpart, dc)
if err != nil { if err != nil {
return fmt.Errorf("getting canonical localpart for fromid login address %q: %v", fa.Localpart, err) return fmt.Errorf("%w: getting canonical localpart for fromid login address %q: %v", ErrRequest, fa.Localpart, err)
} }
alp, err := CanonicalLocalpart(pa.Localpart, dc) alp, err := CanonicalLocalpart(pa.Localpart, dc)
if err != nil { if err != nil {
return fmt.Errorf("getting canonical part for address: %v", err) 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.
@ -1054,7 +1225,7 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
c := Conf.Dynamic c := Conf.Dynamic
acc, ok := c.Accounts[account] acc, ok := c.Accounts[account]
if !ok { if !ok {
return fmt.Errorf("account not present") return fmt.Errorf("%w: account not present", ErrRequest)
} }
xmodify(&acc) xmodify(&acc)
@ -1101,7 +1272,7 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
domConf, ok := Conf.Domain(d) domConf, ok := Conf.Domain(d)
if !ok { if !ok {
return ClientConfig{}, fmt.Errorf("unknown domain") return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
} }
gather := func(l config.Listener) (done bool) { gather := func(l config.Listener) (done bool) {
@ -1159,7 +1330,7 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
return return
} }
} }
return ClientConfig{}, fmt.Errorf("no listeners found for imap and/or submission") return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
} }
// ClientConfigs holds the client configuration for IMAP/Submission for a // ClientConfigs holds the client configuration for IMAP/Submission for a
@ -1181,7 +1352,7 @@ type ClientConfigsEntry struct {
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := Conf.Domain(d) domConf, ok := Conf.Domain(d)
if !ok { if !ok {
return ClientConfigs{}, fmt.Errorf("unknown domain") return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
} }
c := ClientConfigs{} c := ClientConfigs{}

View file

@ -1176,11 +1176,13 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
addErrorf("rsa keys should be >= 1024 bits") addErrorf("rsa keys should be >= 1024 bits")
} }
sel.Key = k sel.Key = k
sel.Algorithm = fmt.Sprintf("rsa-%d", k.N.BitLen())
case ed25519.PrivateKey: case ed25519.PrivateKey:
if sel.HashEffective != "sha256" { if sel.HashEffective != "sha256" {
addErrorf("hash algorithm %q is not supported with ed25519, only sha256 is", sel.HashEffective) addErrorf("hash algorithm %q is not supported with ed25519, only sha256 is", sel.HashEffective)
} }
sel.Key = k sel.Key = k
sel.Algorithm = "ed25519"
default: default:
addErrorf("private key type %T not yet supported, at selector %s in domain %s", key, name, d) addErrorf("private key type %T not yet supported, at selector %s in domain %s", key, name, d)
} }

View file

@ -194,8 +194,8 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
if err == nil { if err == nil {
return return
} }
// If caller tried saving a config that is invalid, cause a user error. // If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
if errors.Is(err, mox.ErrConfig) { if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) {
xcheckuserf(ctx, err, format, args...) xcheckuserf(ctx, err, format, args...)
} }
@ -2443,3 +2443,154 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
}) })
xcheckf(ctx, err, "saving global routes") xcheckf(ctx, err, "saving global routes")
} }
// DomainDescriptionSave saves the description for a domain.
func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
domain.Description = descr
})
xcheckf(ctx, err, "saving domain description")
}
// DomainClientSettingsDomainSave saves the client settings domain for a domain.
func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
domain.ClientSettingsDomain = clientSettingsDomain
})
xcheckf(ctx, err, "saving client settings domain")
}
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain.
func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) {
domain.LocalpartCatchallSeparator = localpartCatchallSeparator
domain.LocalpartCaseSensitive = localpartCaseSensitive
})
xcheckf(ctx, err, "saving localpart settings for domain")
}
// DomainDMARCAddressSave saves the DMARC reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
if localpart == "" {
d.DMARC = nil
} else {
d.DMARC = &config.DMARC{
Localpart: localpart,
Domain: domain,
Account: account,
Mailbox: mailbox,
}
}
})
xcheckf(ctx, err, "saving dmarc reporting address/settings for domain")
}
// DomainTLSRPTAddressSave saves the TLS reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
if localpart == "" {
d.TLSRPT = nil
} else {
d.TLSRPT = &config.TLSRPT{
Localpart: localpart,
Domain: domain,
Account: account,
Mailbox: mailbox,
}
}
})
xcheckf(ctx, err, "saving tls reporting address/settings for domain")
}
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served.
func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
if policyID == "" {
d.MTASTS = nil
} else {
d.MTASTS = &config.MTASTS{
PolicyID: policyID,
Mode: mode,
MaxAge: maxAge,
MX: mx,
}
}
})
xcheckf(ctx, err, "saving mtasts policy for domain")
}
// DomainDKIMAdd adds a DKIM selector for a domain, generating a new private
// key. The selector is not enabled for signing.
func (Admin) DomainDKIMAdd(ctx context.Context, domainName, selector, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) {
d, err := dns.ParseDomain(domainName)
xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime)
xcheckf(ctx, err, "adding dkim key")
}
// DomainDKIMRemove removes a DKIM selector for a domain.
func (Admin) DomainDKIMRemove(ctx context.Context, domainName, selector string) {
d, err := dns.ParseDomain(domainName)
xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMRemove(ctx, d, s)
xcheckf(ctx, err, "removing dkim key")
}
// DomainDKIMSave saves the settings of selectors, and which to enable for
// signing, for a domain. All currently configured selectors must be present,
// selectors cannot be added/removed with this function.
func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors map[string]config.Selector, sign []string) {
for _, s := range sign {
if _, ok := selectors[s]; !ok {
xcheckuserf(ctx, fmt.Errorf("cannot sign unknown selector %q", s), "checking selectors")
}
}
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) {
if len(selectors) != len(d.DKIM.Selectors) {
xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors")
}
for s := range selectors {
if _, ok := d.DKIM.Selectors[s]; !ok {
xcheckuserf(ctx, fmt.Errorf("unknown selector %q", s), "checking selectors")
}
}
// At least the selectors are the same.
// Build up new selectors.
sels := map[string]config.Selector{}
for name, nsel := range selectors {
osel := d.DKIM.Selectors[name]
xsel := config.Selector{
Hash: nsel.Hash,
Canonicalization: nsel.Canonicalization,
DontSealHeaders: nsel.DontSealHeaders,
Expiration: nsel.Expiration,
PrivateKeyFile: osel.PrivateKeyFile,
}
if !slices.Equal(osel.HeadersEffective, nsel.Headers) {
xsel.Headers = nsel.Headers
}
sels[name] = xsel
}
// Enable the new selector settings.
d.DKIM = config.DKIM{
Selectors: sels,
Sign: sign,
}
})
xcheckf(ctx, err, "saving dkim selector for domain")
}

View file

@ -374,7 +374,7 @@ var api;
"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"] }] },
"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"] }] }, "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"] }] },
"DMARC": { "Name": "DMARC", "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"] }] }, "DMARC": { "Name": "DMARC", "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"] }] },
"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"] }] },
@ -1270,6 +1270,87 @@ var api;
const params = [routes]; const params = [routes];
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);
} }
// DomainDescriptionSave saves the description for a domain.
async DomainDescriptionSave(domainName, descr) {
const fn = "DomainDescriptionSave";
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [domainName, descr];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainClientSettingsDomainSave saves the client settings domain for a domain.
async DomainClientSettingsDomainSave(domainName, clientSettingsDomain) {
const fn = "DomainClientSettingsDomainSave";
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [domainName, clientSettingsDomain];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain.
async DomainLocalpartConfigSave(domainName, localpartCatchallSeparator, localpartCaseSensitive) {
const fn = "DomainLocalpartConfigSave";
const paramTypes = [["string"], ["string"], ["bool"]];
const returnTypes = [];
const params = [domainName, localpartCatchallSeparator, localpartCaseSensitive];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainDMARCAddressSave saves the DMARC reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
async DomainDMARCAddressSave(domainName, localpart, domain, account, mailbox) {
const fn = "DomainDMARCAddressSave";
const paramTypes = [["string"], ["string"], ["string"], ["string"], ["string"]];
const returnTypes = [];
const params = [domainName, localpart, domain, account, mailbox];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainTLSRPTAddressSave saves the TLS reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
async DomainTLSRPTAddressSave(domainName, localpart, domain, account, mailbox) {
const fn = "DomainTLSRPTAddressSave";
const paramTypes = [["string"], ["string"], ["string"], ["string"], ["string"]];
const returnTypes = [];
const params = [domainName, localpart, domain, account, mailbox];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served.
async DomainMTASTSSave(domainName, policyID, mode, maxAge, mx) {
const fn = "DomainMTASTSSave";
const paramTypes = [["string"], ["string"], ["Mode"], ["int64"], ["[]", "string"]];
const returnTypes = [];
const params = [domainName, policyID, mode, maxAge, mx];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainDKIMAdd adds a DKIM selector for a domain, generating a new private
// key. The selector is not enabled for signing.
async DomainDKIMAdd(domainName, selector, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime) {
const fn = "DomainDKIMAdd";
const paramTypes = [["string"], ["string"], ["string"], ["string"], ["bool"], ["bool"], ["bool"], ["[]", "string"], ["int64"]];
const returnTypes = [];
const params = [domainName, selector, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainDKIMRemove removes a DKIM selector for a domain.
async DomainDKIMRemove(domainName, selector) {
const fn = "DomainDKIMRemove";
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [domainName, selector];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainDKIMSave saves the settings of selectors, and which to enable for
// signing, for a domain. All currently configured selectors must be present,
// selectors cannot be added/removed with this function.
async DomainDKIMSave(domainName, selectors, sign) {
const fn = "DomainDKIMSave";
const paramTypes = [["string"], ["{}", "Selector"], ["[]", "string"]];
const returnTypes = [];
const params = [domainName, selectors, sign];
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 () {
@ -2175,6 +2256,59 @@ const account = async (name) => {
window.location.hash = '#accounts'; window.location.hash = '#accounts';
})); }));
}; };
const second = 1000 * 1000 * 1000;
const minute = 60 * second;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
const parseDuration = (s) => {
if (!s) {
return 0;
}
const xparseint = () => {
const v = parseInt(s.substring(0, s.length - 1));
if (isNaN(v) || Math.round(v) !== v) {
throw new Error('bad number in duration');
}
return v;
};
if (s.endsWith('w')) {
return xparseint() * week;
}
if (s.endsWith('d')) {
return xparseint() * day;
}
if (s.endsWith('h')) {
return xparseint() * hour;
}
if (s.endsWith('m')) {
return xparseint() * minute;
}
if (s.endsWith('s')) {
return xparseint() * second;
}
throw new Error('bad duration ' + s);
};
const formatDuration = (v, goDuration) => {
if (v === 0) {
return '';
}
const is = (period) => v > 0 && Math.round(v / period) === v / period;
const format = (period, s) => '' + (v / period) + s;
if (!goDuration && is(week)) {
return format(week, 'w');
}
if (!goDuration && is(day)) {
return format(day, 'd');
}
if (is(hour)) {
return format(hour, 'h');
}
if (is(minute)) {
return format(minute, 'm');
}
return format(second, 's');
};
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);
@ -2188,10 +2322,72 @@ const domain = async (d) => {
client.DomainConfig(d), client.DomainConfig(d),
client.Transports(), client.Transports(),
]); ]);
let form; let addrForm;
let addrFieldset;
let addrLocalpart;
let addrAccount;
let descrFieldset;
let descrText;
let clientSettingsDomainFieldset;
let clientSettingsDomain;
let localpartFieldset;
let localpartCatchallSeparator;
let localpartCaseSensitive;
let dmarcFieldset;
let dmarcLocalpart;
let dmarcDomain;
let dmarcAccount;
let dmarcMailbox;
let tlsrptFieldset;
let tlsrptLocalpart;
let tlsrptDomain;
let tlsrptAccount;
let tlsrptMailbox;
let mtastsFieldset;
let mtastsPolicyID;
let mtastsMode;
let mtastsMaxAge;
let mtastsMX;
const popupDKIMHeaders = (sel, span) => {
const l = sel.HeadersEffective || [];
let headers;
const close = popup(dom.h1('Headers to sign with DKIM'), dom.p('Headers signed with DKIM cannot be modified in transit, or the signature would fail to verify. Headers that could influence how messages are interpreted are best DKIM-signed.'), dom.form(function submit(e) {
e.preventDefault();
e.stopPropagation();
sel.HeadersEffective = headers.value.split('\n').map(s => s.trim()).filter(s => s);
dom._kids(span, (sel.HeadersEffective || []).join('; '));
close();
}, dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Headers', dom.div(headers = dom.textarea(new String(l.join('\n')), attr.rows('' + Math.max(2, 1 + l.length))))), dom.div(dom.submitbutton('OK')), dom.br(), dom.p("Changes are not yet saved after closing the popup. Don't forget to save.")));
};
const popupDKIMAdd = () => {
let fieldset; let fieldset;
let localpart; let selector;
let account; let algorithm;
let hash;
let canonHeader;
let canonBody;
let seal;
let headers;
let lifetime;
const defaultSelector = () => {
const d = new Date();
let s = '' + d.getFullYear();
let mon = '' + (1 + d.getMonth());
s += mon.length === 1 ? '0' + mon : mon;
s += 'a';
return s;
};
popup(style({ minWidth: '30em' }), dom.h1('Add DKIM key/selector'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
if (!window.confirm('Are you sure? A key will be generated by the server, the selector configured but disabled. The page will reload, so unsaved changes to other DKIM selectors will be lost. After adding the key, first add the selector to DNS, then enable it for signing outgoing messages.')) {
return;
}
await check(fieldset, (async () => client.DomainDKIMAdd(d, selector.value, algorithm.value, hash.value, canonHeader.value === 'relaxed', canonBody.value === 'relaxed', seal.checked, headers.value.split('\n').map(s => s.trim()).filter(s => s), parseDuration(lifetime.value)))());
window.alert("Selector added. Page will be reloaded. Don't forget to add the selector to DNS, see suggested DNS records, and don't forget to enable the selector afterwards.");
window.location.reload(); // todo: reload only dkim section
}, fieldset = dom.fieldset(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey.<domain>.'), dom.div(selector = dom.input(attr.required(''), attr.value(defaultSelector())))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.'), dom.div(algorithm = dom.select(dom.option('rsa'), dom.option('ed25519')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."), dom.div(hash = dom.select(dom.option('sha256')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - header', attr.title('Canonicalization processes the message headers before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.'), dom.div(canonHeader = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - body', attr.title('Like canonicalization for headers, but for the bodies.'), dom.div(canonBody = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.'), dom.div(lifetime = dom.input(attr.value('3d'), attr.required('')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option."), dom.div(seal = dom.input(attr.type('checkbox'), attr.checked(''))))), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Headers (optional)', attr.title('Headers to sign. If left empty, a set of standard headers are signed. The (standard set of) headers are most easily edited after creating the selector/key.'), dom.div(headers = dom.textarea(attr.rows('15')))))), dom.div(dom.submitbutton('Add')))));
};
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you sure you want to remove this address?')) { if (!window.confirm('Are you sure you want to remove this address?')) {
@ -2199,13 +2395,174 @@ const domain = async (d) => {
} }
await check(e.target, client.AddressRemove(t[0] + '@' + d)); await check(e.target, client.AddressRemove(t[0] + '@' + d));
window.location.reload(); // todo: only reload the localparts window.location.reload(); // todo: only reload the localparts
})))))), dom.br(), dom.h2('Add address'), form = dom.form(async function submit(e) { })))))), dom.br(), dom.h2('Add address'), addrForm = dom.form(async function submit(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await check(fieldset, client.AddressAdd(localpart.value + '@' + d, account.value)); await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value));
form.reset(); addrForm.reset();
window.location.reload(); // todo: only reload the addresses window.location.reload(); // todo: only reload the addresses
}, fieldset = 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(), localpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), account = 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('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', async function click(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(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value));
}, descrFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Free-form description of domain.'), dom.div('Description'), descrText = dom.input(attr.value(domainConfig.Description), style({ width: '30em' }))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.form(style({ marginTop: '1ex' }), async function submit(e) {
e.preventDefault();
e.stopPropagation();
await check(clientSettingsDomainFieldset, client.DomainClientSettingsDomainSave(d, clientSettingsDomain.value));
}, clientSettingsDomainFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Hostname for client settings instead of the mail server hostname. E.g. mail.<domain>. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name.'), dom.div('Client settings domain'), clientSettingsDomain = dom.input(attr.value(domainConfig.ClientSettingsDomain), style({ width: '30em' }))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.form(style({ marginTop: '1ex' }), async function submit(e) {
e.preventDefault();
e.stopPropagation();
await check(localpartFieldset, client.DomainLocalpartConfigSave(d, localpartCatchallSeparator.value, localpartCaseSensitive.checked));
}, localpartFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('If set, upper/lower case is relevant for email delivery.'), dom.div('Localpart case sensitive'), localpartCaseSensitive = dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : [])), dom.label(attr.title('If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'), dom.div('Localpart catchall separator'), localpartCatchallSeparator = dom.input(attr.value(domainConfig.LocalpartCatchallSeparator))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('DMARC reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) {
e.preventDefault();
e.stopPropagation();
if (!dmarcLocalpart.value) {
dmarcDomain.value = '';
dmarcAccount.value = '';
dmarcMailbox.value = '';
}
const needChange = (dmarcLocalpart.value === '') !== (domainConfig.DMARC === null) || domainConfig.DMARC && (domainConfig.DMARC.Localpart !== dmarcLocalpart.value || domainConfig.DMARC?.Domain !== dmarcDomain.value);
await check(dmarcFieldset, client.DomainDMARCAddressSave(d, dmarcLocalpart.value, dmarcDomain.value, dmarcAccount.value, dmarcMailbox.value));
if (needChange) {
window.alert('Do not forget to update the DNS records with the updated reporting address (rua).');
if (dmarcLocalpart.value) {
domainConfig.DMARC = { Localpart: dmarcLocalpart.value, Domain: dmarcDomain.value, Account: dmarcAccount.value, Mailbox: dmarcMailbox.value, ParsedLocalpart: '', DNSDomain: { ASCII: '', Unicode: '' } };
}
else {
domainConfig.DMARC = null;
}
}
}, dmarcFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports.'), dom.div('Localpart'), dmarcLocalpart = dom.input(attr.value(domainConfig.DMARC?.Localpart || ''))), dom.label(attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing this domain 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 hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the DNS settings for this domain. Unicode name."), dom.div('Alternative domain (optional)'), dmarcDomain = dom.input(attr.value(domainConfig.DMARC?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), dmarcAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(s, s === domainConfig.DMARC?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. DMARC.'), dom.div('Mailbox'), dmarcMailbox = dom.input(attr.value(domainConfig.DMARC?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('TLS reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) {
e.preventDefault();
e.stopPropagation();
if (!tlsrptLocalpart.value) {
tlsrptDomain.value = '';
tlsrptAccount.value = '';
tlsrptMailbox.value = '';
}
const needChange = (tlsrptLocalpart.value === '') !== (domainConfig.TLSRPT === null) || domainConfig.TLSRPT && (domainConfig.TLSRPT.Localpart !== tlsrptLocalpart.value || domainConfig.TLSRPT?.Domain !== tlsrptDomain.value);
await check(tlsrptFieldset, client.DomainTLSRPTAddressSave(d, tlsrptLocalpart.value, tlsrptDomain.value, tlsrptAccount.value, tlsrptMailbox.value));
if (needChange) {
window.alert('Do not forget to update the DNS records with the updated reporting address (rua).');
if (tlsrptLocalpart.value) {
domainConfig.TLSRPT = { Localpart: tlsrptLocalpart.value, Domain: tlsrptDomain.value, Account: tlsrptAccount.value, Mailbox: tlsrptMailbox.value, ParsedLocalpart: '', DNSDomain: { ASCII: '', Unicode: '' } };
}
else {
domainConfig.TLSRPT = null;
}
}
}, tlsrptFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts TLSRPT reports. Must be non-internationalized. Recommended value: tlsrpt-reports.'), dom.div('Localpart'), tlsrptLocalpart = dom.input(attr.value(domainConfig.TLSRPT?.Localpart || ''))), dom.label(attr.title("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 TLSRPT 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."), dom.div('Alternative domain (optional)'), tlsrptDomain = dom.input(attr.value(domainConfig.TLSRPT?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), tlsrptAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(s, s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. TLSRPT.'), dom.div('Mailbox'), tlsrptMailbox = dom.input(attr.value(domainConfig.TLSRPT?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('MTA-STS policy', attr.title("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.")), dom.form(style({ marginTop: '1ex' }), async function submit(e) {
e.preventDefault();
e.stopPropagation();
let mx = [];
let mode = api.Mode.ModeNone;
let maxAge = 0;
if (!mtastsPolicyID.value) {
mtastsMode.value = '';
mtastsMaxAge.value = '';
mtastsMX.value = '';
if (domainConfig.MTASTS?.PolicyID && !window.confirm('Are you sure you want to remove the MTA-STS policy? Only remove policies after having served a policy with mode "none" for a long enough period, so all previously served and remotely cached policies have expired past the then-configured DNS TTL plus policy max-age period, and seen the policy with mode "none".')) {
return;
}
}
else {
if (!mtastsMode.value) {
throw new Error('mode is required for an active policy');
}
mode = mtastsMode.value;
maxAge = parseDuration(mtastsMaxAge.value);
mx = mtastsMX.value ? mtastsMX.value.split('\n') : [];
if (domainConfig.MTASTS?.PolicyID === mtastsPolicyID.value && !window.confirm('Are you sure you want to save the policy without updating the policy ID? Remote servers may hold on to the old cached policies. Policy IDs should be changed when the policy is changed. Remember to first update the policy here, then publish the new policy ID in DNS.')) {
return;
}
}
await check(mtastsFieldset, client.DomainMTASTSSave(d, mtastsPolicyID.value, mode, maxAge, mx));
if (domainConfig.MTASTS?.PolicyID === mtastsPolicyID.value) {
return;
}
if (domainConfig.MTASTS?.PolicyID && !mtastsPolicyID.value) {
window.alert("Don't forget to remove the MTA-STS DNS record.");
domainConfig.MTASTS = null;
}
else if (mtastsPolicyID.value) {
if (mtastsPolicyID.value !== domainConfig.MTASTS?.PolicyID) {
window.alert("Don't forget to update the MTA-STS DNS record with the new policy ID, see suggested DNS records.");
}
domainConfig.MTASTS = {
PolicyID: mtastsPolicyID.value,
Mode: mode,
MaxAge: maxAge,
MX: mx,
};
}
}, mtastsFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it here to update the served policy, then update the DNS record with the updated policy ID.'), dom.div('Policy ID ', dom.a('generate', attr.href(''), attr.title('Generate new policy ID based on current time.'), function click(e) {
e.preventDefault();
// 20060102T150405
mtastsPolicyID.value = new Date().toISOString().replace(/-/g, '').replace(/:/g, '').split('.')[0];
})), mtastsPolicyID = dom.input(attr.value(domainConfig.MTASTS?.PolicyID || ''))), dom.label(attr.title("If set to \"enforce\", a remote SMTP server will not deliver email to us if it cannot make a WebPKI-verified SMTP STARTTLS connection. In mode \"testing\", deliveries can be done without verified TLS, but errors will be reported through TLS reporting. In mode \"none\", verified TLS is not required, used for phasing out an MTA-STS policy."), dom.div('Mode'), mtastsMode = dom.select(dom.option(''), Object.values(api.Mode).map(s => dom.option(s, domainConfig.MTASTS?.Mode === s ? attr.selected('') : [])))), dom.label(attr.title('How long a remote mail server is allowed to cache a policy. Typically 1 or several weeks. Units: s for seconds, m for minutes, h for hours, d for day, w for weeks.'), dom.div('Max age'), mtastsMaxAge = dom.input(attr.value(domainConfig.MTASTS?.MaxAge ? formatDuration(domainConfig.MTASTS?.MaxAge || 0) : ''))), dom.label(attr.title('List of server names allowed for SMTP. If empty, the configured hostname is set. Host names can contain a wildcard (*) as a leading label (matching a single label, e.g. *.example matches host.example, not sub.host.example).'), dom.div('MX hosts/patterns (optional)'), mtastsMX = dom.textarea(new String((domainConfig.MTASTS?.MX || []).join('\n')), attr.rows('' + Math.max(2, 1 + (domainConfig.MTASTS?.MX || []).length)))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('DKIM', attr.title('With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery.')), (() => {
let fieldset;
let rows = [];
return dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
if (!window.confirm("Are you sure you want to save changes to DKIM selectors?")) {
return;
}
const selectors = {};
const sign = [];
for (const row of rows) {
const [selName, enabled, sel] = row.gather();
sel.Expiration = formatDuration(parseDuration(sel.Expiration), true);
selectors[selName] = sel;
if (enabled) {
sign.push(selName);
}
}
await check(fieldset, client.DomainDKIMSave(d, selectors, sign));
window.alert("Don't forget to update DNS records if needed. See suggested DNS records.");
}, fieldset = dom.fieldset(dom.table(dom.thead(dom.tr(dom.th('Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey.<domain>.')), dom.th('Enabled', attr.title('Whether a DKIM-Signature is added to messages for this message. Multiple selectors can be enabled. Having backup keys published in DNS can be useful for quickly rotating a key.')), dom.th('Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.')), dom.th('Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences.")), dom.th('Canonicalization header/body', attr.colspan('2'), attr.title('Canonicalization processes the message headers and bodies before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.')), dom.th('Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option.")), dom.th('Headers', attr.title('Headers to sign.')), dom.th('Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.')), dom.th('Action'))), dom.tbody(Object.keys(domainConfig.DKIM.Selectors || []).length === 0 ? dom.tr(dom.td(attr.colspan('9'), 'No DKIM keys/selectors.')) : [], rows = Object.entries(domainConfig.DKIM.Selectors || []).sort().map(([selName, sel]) => {
let enabled;
let hash;
let canonHeader;
let canonBody;
let seal;
let headersElem;
let lifetime;
const tr = dom.tr(dom.td(selName), dom.td(enabled = dom.input(attr.type('checkbox'), (domainConfig.DKIM.Sign || []).includes(selName) ? attr.checked('') : [])), dom.td(sel.Algorithm), dom.td(hash = dom.select(dom.option('sha256', sel.HashEffective === 'sha256' ? attr.selected('') : []), dom.option('sha1', sel.HashEffective === 'sha1' ? attr.selected('') : []))), dom.td(canonHeader = dom.select(dom.option('relaxed'), dom.option('simple', sel.Canonicalization.HeaderRelaxed ? [] : attr.selected('')))), dom.td(canonBody = dom.select(dom.option('relaxed'), dom.option('simple', sel.Canonicalization.BodyRelaxed ? [] : attr.selected('')))), dom.td(seal = dom.input(attr.type('checkbox'), sel.DontSealHeaders ? [] : attr.checked(''))), dom.td(headersElem = dom.span((sel.HeadersEffective || []).join('; ')), ' ', dom.a(attr.href(''), 'Edit', function click(e) {
e.preventDefault();
popupDKIMHeaders(sel, headersElem);
})), dom.td(lifetime = dom.input(attr.value(sel.Expiration))), dom.td(dom.clickbutton('Remove', async function click(e) {
if (!window.confirm('Are you sure you want to remove this selector? It is removed immediately, after which the page is reloaded, losing unsaved changes.')) {
return;
}
await check(e.target, client.DomainDKIMRemove(d, selName));
window.alert("Don't forget to remove the corresponding DNS records (if it exists). If the DKIM key was active, it is best to wait for all messages in transit have been delivered (which can take days if messages are held up in remote queues), or those messages will not pass DKIM validiation.");
window.location.reload(); // todo: reload less
})));
return {
root: tr,
gather: () => {
const nsel = {
Hash: hash.value,
HashEffective: hash.value,
Canonicalization: {
HeaderRelaxed: canonHeader.value === 'relaxed',
BodyRelaxed: canonBody.value === 'relaxed',
},
Headers: sel.HeadersEffective,
HeadersEffective: sel.HeadersEffective,
DontSealHeaders: !seal.checked,
Expiration: lifetime.value,
PrivateKeyFile: '',
Algorithm: '',
};
return [selName, enabled.checked, nsel];
},
};
})), dom.tfoot(dom.tr(dom.td(attr.colspan('9'), dom.submitbutton('Save'), ' ', dom.clickbutton('Add key/selector', function click() {
popupDKIMAdd();
})))))));
})(), dom.br(), dom.h2('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', async function click(e) {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you sure you want to remove this domain?')) { if (!window.confirm('Are you sure you want to remove this domain?')) {
return; return;

View file

@ -972,6 +972,40 @@ const account = async (name: string) => {
) )
} }
const second = 1000*1000*1000
const minute = 60*second
const hour = 60*minute
const day = 24*hour
const week = 7*day
const parseDuration = (s: string) => {
if (!s) { return 0 }
const xparseint = () => {
const v = parseInt(s.substring(0, s.length-1))
if (isNaN(v) || Math.round(v) !== v) {
throw new Error('bad number in duration')
}
return v
}
if (s.endsWith('w')) { return xparseint()*week }
if (s.endsWith('d')) { return xparseint()*day }
if (s.endsWith('h')) { return xparseint()*hour }
if (s.endsWith('m')) { return xparseint()*minute }
if (s.endsWith('s')) { return xparseint()*second }
throw new Error('bad duration '+s)
}
const formatDuration = (v: number, goDuration?: boolean) => {
if (v === 0) {
return ''
}
const is = (period: number) => v > 0 && Math.round(v/period) === v/period
const format = (period: number, s: string) => ''+(v/period)+s
if (!goDuration && is(week)) { return format(week, 'w') }
if (!goDuration && is(day)) { return format(day, 'd') }
if (is(hour)) { return format(hour, 'h') }
if (is(minute)) { return format(minute, 'm') }
return format(second, 's')
}
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)
@ -986,10 +1020,164 @@ const domain = async (d: string) => {
client.Transports(), client.Transports(),
]) ])
let form: HTMLFormElement let addrForm: HTMLFormElement
let addrFieldset: HTMLFieldSetElement
let addrLocalpart: HTMLInputElement
let addrAccount: HTMLSelectElement
let descrFieldset: HTMLFieldSetElement
let descrText: HTMLInputElement
let clientSettingsDomainFieldset: HTMLFieldSetElement
let clientSettingsDomain: HTMLInputElement
let localpartFieldset: HTMLFieldSetElement
let localpartCatchallSeparator: HTMLInputElement
let localpartCaseSensitive: HTMLInputElement
let dmarcFieldset: HTMLFieldSetElement
let dmarcLocalpart: HTMLInputElement
let dmarcDomain: HTMLInputElement
let dmarcAccount: HTMLSelectElement
let dmarcMailbox: HTMLInputElement
let tlsrptFieldset: HTMLFieldSetElement
let tlsrptLocalpart: HTMLInputElement
let tlsrptDomain: HTMLInputElement
let tlsrptAccount: HTMLSelectElement
let tlsrptMailbox: HTMLInputElement
let mtastsFieldset: HTMLFieldSetElement
let mtastsPolicyID: HTMLInputElement
let mtastsMode: HTMLSelectElement
let mtastsMaxAge: HTMLInputElement
let mtastsMX: HTMLTextAreaElement
const popupDKIMHeaders = (sel: api.Selector, span: HTMLSpanElement) => {
const l = sel.HeadersEffective || []
let headers: HTMLTextAreaElement
const close = popup(
dom.h1('Headers to sign with DKIM'),
dom.p('Headers signed with DKIM cannot be modified in transit, or the signature would fail to verify. Headers that could influence how messages are interpreted are best DKIM-signed.'),
dom.form(
function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
sel.HeadersEffective = headers.value.split('\n').map(s => s.trim()).filter(s => s)
dom._kids(span, (sel.HeadersEffective || []).join('; '))
close()
},
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Headers',
dom.div(
headers=dom.textarea(new String(l.join('\n')), attr.rows(''+Math.max(2, 1+l.length))),
),
),
dom.div(dom.submitbutton('OK')),
dom.br(),
dom.p("Changes are not yet saved after closing the popup. Don't forget to save."),
),
)
}
const popupDKIMAdd = () => {
let fieldset: HTMLFieldSetElement let fieldset: HTMLFieldSetElement
let localpart: HTMLInputElement let selector: HTMLInputElement
let account: HTMLSelectElement let algorithm: HTMLSelectElement
let hash: HTMLSelectElement
let canonHeader: HTMLSelectElement
let canonBody: HTMLSelectElement
let seal: HTMLInputElement
let headers: HTMLTextAreaElement
let lifetime: HTMLInputElement
const defaultSelector = () => {
const d = new Date()
let s = ''+d.getFullYear()
let mon = ''+(1+d.getMonth())
s += mon.length === 1 ? '0'+mon : mon
s += 'a'
return s
}
popup(
style({minWidth: '30em'}),
dom.h1('Add DKIM key/selector'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
if (!window.confirm('Are you sure? A key will be generated by the server, the selector configured but disabled. The page will reload, so unsaved changes to other DKIM selectors will be lost. After adding the key, first add the selector to DNS, then enable it for signing outgoing messages.')) {
return
}
await check(fieldset, (async () => client.DomainDKIMAdd(d, selector.value, algorithm.value, hash.value, canonHeader.value === 'relaxed', canonBody.value === 'relaxed', seal.checked, headers.value.split('\n').map(s => s.trim()).filter(s => s), parseDuration(lifetime.value)))())
window.alert("Selector added. Page will be reloaded. Don't forget to add the selector to DNS, see suggested DNS records, and don't forget to enable the selector afterwards.")
window.location.reload() // todo: reload only dkim section
},
fieldset=dom.fieldset(
dom.div(
style({display: 'flex', gap: '1em'}),
dom.div(
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Selector',
attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey.<domain>.'),
dom.div(selector=dom.input(attr.required(''), attr.value(defaultSelector()))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Algorithm',
attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.'),
dom.div(algorithm=dom.select(dom.option('rsa'), dom.option('ed25519'))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Hash',
attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."),
dom.div(hash=dom.select(dom.option('sha256'))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Canonicalization - header',
attr.title('Canonicalization processes the message headers before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.'),
dom.div(canonHeader=dom.select(dom.option('relaxed'), dom.option('simple'))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Canonicalization - body',
attr.title('Like canonicalization for headers, but for the bodies.'),
dom.div(canonBody=dom.select(dom.option('relaxed'), dom.option('simple'))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Signature lifetime',
attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.'),
dom.div(lifetime=dom.input(attr.value('3d'), attr.required(''))),
),
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Seal headers',
attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option."),
dom.div(seal=dom.input(attr.type('checkbox'), attr.checked(''))),
),
),
dom.div(
dom.label(
style({display: 'block', marginBottom: '1ex'}),
'Headers (optional)',
attr.title('Headers to sign. If left empty, a set of standard headers are signed. The (standard set of) headers are most easily edited after creating the selector/key.'),
dom.div(headers=dom.textarea(attr.rows('15'))),
),
),
),
dom.div(dom.submitbutton('Add')),
),
),
)
}
dom._kids(page, dom._kids(page,
crumbs( crumbs(
@ -1001,6 +1189,7 @@ const domain = async (d: string) => {
dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck'))),
), ),
dom.br(), dom.br(),
dom.h2('Client configuration'), dom.h2('Client configuration'),
dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'),
dom.table( dom.table(
@ -1022,12 +1211,15 @@ const domain = async (d: string) => {
), ),
), ),
dom.br(), dom.br(),
dom.h2('DMARC aggregate reports summary'), dom.h2('DMARC aggregate reports summary'),
renderDMARCSummaries(dmarcSummaries || []), renderDMARCSummaries(dmarcSummaries || []),
dom.br(), dom.br(),
dom.h2('TLS reports summary'), dom.h2('TLS reports summary'),
renderTLSRPTSummaries(tlsrptSummaries || []), renderTLSRPTSummaries(tlsrptSummaries || []),
dom.br(), dom.br(),
dom.h2('Addresses'), dom.h2('Addresses'),
dom.table( dom.table(
dom.thead( dom.thead(
@ -1055,21 +1247,22 @@ const domain = async (d: string) => {
), ),
), ),
dom.br(), dom.br(),
dom.h2('Add address'), dom.h2('Add address'),
form=dom.form( addrForm=dom.form(
async function submit(e: SubmitEvent) { async function submit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
await check(fieldset, client.AddressAdd(localpart.value+'@'+d, account.value)) await check(addrFieldset, client.AddressAdd(addrLocalpart.value+'@'+d, addrAccount.value))
form.reset() addrForm.reset()
window.location.reload() // todo: only reload the addresses window.location.reload() // todo: only reload the addresses
}, },
fieldset=dom.fieldset( addrFieldset=dom.fieldset(
dom.label( dom.label(
style({display: 'inline-block'}), 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.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(), dom.br(),
localpart=dom.input(), addrLocalpart=dom.input(),
), ),
'@', domainName(dnsdomain), '@', domainName(dnsdomain),
' ', ' ',
@ -1077,20 +1270,404 @@ const domain = async (d: string) => {
style({display: 'inline-block'}), style({display: 'inline-block'}),
dom.span('Account', attr.title('Account to assign the address to.')), dom.span('Account', attr.title('Account to assign the address to.')),
dom.br(), dom.br(),
account=dom.select(attr.required(''), (accounts || []).map(a => dom.option(a))), 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.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')),
), ),
), ),
dom.br(), 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(),
dom.h2('Settings'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
await check(descrFieldset, client.DomainDescriptionSave(d, descrText.value))
},
descrFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('Free-form description of domain.'),
dom.div('Description'),
descrText=dom.input(attr.value(domainConfig.Description), style({width: '30em'})),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.form(
style({marginTop: '1ex'}),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
await check(clientSettingsDomainFieldset, client.DomainClientSettingsDomainSave(d, clientSettingsDomain.value))
},
clientSettingsDomainFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('Hostname for client settings instead of the mail server hostname. E.g. mail.<domain>. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name.'),
dom.div('Client settings domain'),
clientSettingsDomain=dom.input(attr.value(domainConfig.ClientSettingsDomain), style({width: '30em'})),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.form(
style({marginTop: '1ex'}),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
await check(localpartFieldset, client.DomainLocalpartConfigSave(d, localpartCatchallSeparator.value, localpartCaseSensitive.checked))
},
localpartFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('If set, upper/lower case is relevant for email delivery.'),
dom.div('Localpart case sensitive'),
localpartCaseSensitive=dom.input(attr.type('checkbox'), domainConfig.LocalpartCaseSensitive ? attr.checked('') : []),
),
dom.label(
attr.title('If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com.'),
dom.div('Localpart catchall separator'),
localpartCatchallSeparator=dom.input(attr.value(domainConfig.LocalpartCatchallSeparator)),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.br(),
dom.h2('DMARC reporting address'),
dom.form(
style({marginTop: '1ex'}),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
if (!dmarcLocalpart.value) {
dmarcDomain.value = ''
dmarcAccount.value = ''
dmarcMailbox.value = ''
}
const needChange = (dmarcLocalpart.value === '') !== (domainConfig.DMARC === null) || domainConfig.DMARC && (domainConfig.DMARC.Localpart !== dmarcLocalpart.value || domainConfig.DMARC?.Domain !== dmarcDomain.value)
await check(dmarcFieldset, client.DomainDMARCAddressSave(d, dmarcLocalpart.value, dmarcDomain.value, dmarcAccount.value, dmarcMailbox.value))
if (needChange) {
window.alert('Do not forget to update the DNS records with the updated reporting address (rua).')
if (dmarcLocalpart.value) {
domainConfig.DMARC = {Localpart: dmarcLocalpart.value, Domain: dmarcDomain.value, Account: dmarcAccount.value, Mailbox: dmarcMailbox.value, ParsedLocalpart: '', DNSDomain: {ASCII: '', Unicode: ''}}
} else {
domainConfig.DMARC = null
}
}
},
dmarcFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports.'),
dom.div('Localpart'),
dmarcLocalpart=dom.input(attr.value(domainConfig.DMARC?.Localpart || '')),
),
dom.label(
attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing this domain 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 hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the DNS settings for this domain. Unicode name."),
dom.div('Alternative domain (optional)'),
dmarcDomain=dom.input(attr.value(domainConfig.DMARC?.Domain || '')),
),
dom.label(
attr.title('Account to deliver to.'),
dom.div('Account'),
dmarcAccount=dom.select(
dom.option(''),
(accounts || []).map(s => dom.option(s, s === domainConfig.DMARC?.Account ? attr.selected('') : [])),
),
),
dom.label(
attr.title('Mailbox to deliver to, e.g. DMARC.'),
dom.div('Mailbox'),
dmarcMailbox=dom.input(attr.value(domainConfig.DMARC?.Mailbox || '')),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.br(),
dom.h2('TLS reporting address'),
dom.form(
style({marginTop: '1ex'}),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
if (!tlsrptLocalpart.value) {
tlsrptDomain.value = ''
tlsrptAccount.value = ''
tlsrptMailbox.value = ''
}
const needChange = (tlsrptLocalpart.value === '') !== (domainConfig.TLSRPT === null) || domainConfig.TLSRPT && (domainConfig.TLSRPT.Localpart !== tlsrptLocalpart.value || domainConfig.TLSRPT?.Domain !== tlsrptDomain.value)
await check(tlsrptFieldset, client.DomainTLSRPTAddressSave(d, tlsrptLocalpart.value, tlsrptDomain.value, tlsrptAccount.value, tlsrptMailbox.value))
if (needChange) {
window.alert('Do not forget to update the DNS records with the updated reporting address (rua).')
if (tlsrptLocalpart.value) {
domainConfig.TLSRPT = {Localpart: tlsrptLocalpart.value, Domain: tlsrptDomain.value, Account: tlsrptAccount.value, Mailbox: tlsrptMailbox.value, ParsedLocalpart: '', DNSDomain: {ASCII: '', Unicode: ''}}
} else {
domainConfig.TLSRPT = null
}
}
},
tlsrptFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('Address-part before the @ that accepts TLSRPT reports. Must be non-internationalized. Recommended value: tlsrpt-reports.'),
dom.div('Localpart'),
tlsrptLocalpart=dom.input(attr.value(domainConfig.TLSRPT?.Localpart || '')),
),
dom.label(
attr.title("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 TLSRPT 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."),
dom.div('Alternative domain (optional)'),
tlsrptDomain=dom.input(attr.value(domainConfig.TLSRPT?.Domain || '')),
),
dom.label(
attr.title('Account to deliver to.'),
dom.div('Account'),
tlsrptAccount=dom.select(
dom.option(''),
(accounts || []).map(s => dom.option(s, s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])),
),
),
dom.label(
attr.title('Mailbox to deliver to, e.g. TLSRPT.'),
dom.div('Mailbox'),
tlsrptMailbox=dom.input(attr.value(domainConfig.TLSRPT?.Mailbox || '')),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.br(),
dom.h2('MTA-STS policy', attr.title("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.")),
dom.form(
style({marginTop: '1ex'}),
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
let mx: string[] = []
let mode = api.Mode.ModeNone
let maxAge = 0
if (!mtastsPolicyID.value) {
mtastsMode.value = ''
mtastsMaxAge.value = ''
mtastsMX.value = ''
if (domainConfig.MTASTS?.PolicyID && !window.confirm('Are you sure you want to remove the MTA-STS policy? Only remove policies after having served a policy with mode "none" for a long enough period, so all previously served and remotely cached policies have expired past the then-configured DNS TTL plus policy max-age period, and seen the policy with mode "none".')) {
return
}
} else {
if (!mtastsMode.value) {
throw new Error('mode is required for an active policy')
}
mode = mtastsMode.value as api.Mode
maxAge = parseDuration(mtastsMaxAge.value)
mx = mtastsMX.value ? mtastsMX.value.split('\n') : []
if (domainConfig.MTASTS?.PolicyID === mtastsPolicyID.value && !window.confirm('Are you sure you want to save the policy without updating the policy ID? Remote servers may hold on to the old cached policies. Policy IDs should be changed when the policy is changed. Remember to first update the policy here, then publish the new policy ID in DNS.')) {
return
}
}
await check(mtastsFieldset, client.DomainMTASTSSave(d, mtastsPolicyID.value, mode, maxAge, mx))
if (domainConfig.MTASTS?.PolicyID === mtastsPolicyID.value) {
return
}
if (domainConfig.MTASTS?.PolicyID && !mtastsPolicyID.value) {
window.alert("Don't forget to remove the MTA-STS DNS record.")
domainConfig.MTASTS = null
} else if (mtastsPolicyID.value) {
if (mtastsPolicyID.value !== domainConfig.MTASTS?.PolicyID) {
window.alert("Don't forget to update the MTA-STS DNS record with the new policy ID, see suggested DNS records.")
}
domainConfig.MTASTS = {
PolicyID: mtastsPolicyID.value,
Mode: mode,
MaxAge: maxAge,
MX: mx,
}
}
},
mtastsFieldset=dom.fieldset(
style({display: 'flex', gap: '1em'}),
dom.label(
attr.title('Policies are versioned. The version must be specified in the DNS record. If you change a policy, first change it here to update the served policy, then update the DNS record with the updated policy ID.'),
dom.div(
'Policy ID ',
dom.a('generate', attr.href(''), attr.title('Generate new policy ID based on current time.'), function click(e: MouseEvent) {
e.preventDefault()
// 20060102T150405
mtastsPolicyID.value = new Date().toISOString().replace(/-/g, '').replace(/:/g, '').split('.')[0]
}),
),
mtastsPolicyID=dom.input(attr.value(domainConfig.MTASTS?.PolicyID || '')),
),
dom.label(
attr.title("If set to \"enforce\", a remote SMTP server will not deliver email to us if it cannot make a WebPKI-verified SMTP STARTTLS connection. In mode \"testing\", deliveries can be done without verified TLS, but errors will be reported through TLS reporting. In mode \"none\", verified TLS is not required, used for phasing out an MTA-STS policy."),
dom.div('Mode'),
mtastsMode=dom.select(
dom.option(''),
Object.values(api.Mode).map(s =>
dom.option(
s,
domainConfig.MTASTS?.Mode === s ? attr.selected('') : [],
)
),
),
),
dom.label(
attr.title('How long a remote mail server is allowed to cache a policy. Typically 1 or several weeks. Units: s for seconds, m for minutes, h for hours, d for day, w for weeks.'),
dom.div('Max age'),
mtastsMaxAge=dom.input(attr.value(domainConfig.MTASTS?.MaxAge ? formatDuration(domainConfig.MTASTS?.MaxAge || 0) : '')),
),
dom.label(
attr.title('List of server names allowed for SMTP. If empty, the configured hostname is set. Host names can contain a wildcard (*) as a leading label (matching a single label, e.g. *.example matches host.example, not sub.host.example).'),
dom.div('MX hosts/patterns (optional)'),
mtastsMX=dom.textarea(new String((domainConfig.MTASTS?.MX || []).join('\n')), attr.rows(''+Math.max(2, 1+(domainConfig.MTASTS?.MX || []).length))),
),
dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))),
),
),
dom.br(),
dom.h2('DKIM', attr.title('With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery.')),
(() => {
let fieldset: HTMLFieldSetElement
interface Row {
root: HTMLElement
gather: () => [string, boolean, api.Selector]
}
let rows: Row[] = []
return dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
if (!window.confirm("Are you sure you want to save changes to DKIM selectors?")) {
return
}
const selectors: { [key: string]: api.Selector } = {}
const sign: string[] = []
for (const row of rows) {
const [selName, enabled, sel] = row.gather()
sel.Expiration = formatDuration(parseDuration(sel.Expiration), true)
selectors[selName] = sel
if (enabled) {
sign.push(selName)
}
}
await check(fieldset, client.DomainDKIMSave(d, selectors, sign))
window.alert("Don't forget to update DNS records if needed. See suggested DNS records.")
},
fieldset=dom.fieldset(
dom.table(
dom.thead(
dom.tr(
dom.th('Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey.<domain>.')),
dom.th('Enabled', attr.title('Whether a DKIM-Signature is added to messages for this message. Multiple selectors can be enabled. Having backup keys published in DNS can be useful for quickly rotating a key.')),
dom.th('Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.')),
dom.th('Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."),),
dom.th('Canonicalization header/body', attr.colspan('2'), attr.title('Canonicalization processes the message headers and bodies before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.')),
dom.th('Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option.")),
dom.th('Headers', attr.title('Headers to sign.')),
dom.th('Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.')),
dom.th('Action'),
),
),
dom.tbody(
Object.keys(domainConfig.DKIM.Selectors || []).length === 0 ? dom.tr(dom.td(attr.colspan('9'), 'No DKIM keys/selectors.')) : [],
rows=Object.entries(domainConfig.DKIM.Selectors || []).sort().map(([selName, sel]) => {
let enabled: HTMLInputElement
let hash: HTMLSelectElement
let canonHeader: HTMLSelectElement
let canonBody: HTMLSelectElement
let seal: HTMLInputElement
let headersElem: HTMLSpanElement
let lifetime: HTMLInputElement
const tr = dom.tr(
dom.td(selName),
dom.td(enabled=dom.input(attr.type('checkbox'), (domainConfig.DKIM.Sign || []).includes(selName) ? attr.checked('') : [])),
dom.td(sel.Algorithm),
dom.td(
hash=dom.select(
dom.option('sha256', sel.HashEffective === 'sha256' ? attr.selected('') : []),
dom.option('sha1', sel.HashEffective === 'sha1' ? attr.selected('') : []),
),
),
dom.td(
canonHeader=dom.select(dom.option('relaxed'), dom.option('simple', sel.Canonicalization.HeaderRelaxed ? [] : attr.selected(''))),
),
dom.td(
canonBody=dom.select(dom.option('relaxed'), dom.option('simple', sel.Canonicalization.BodyRelaxed ? [] : attr.selected(''))),
),
dom.td(seal=dom.input(attr.type('checkbox'), sel.DontSealHeaders ? [] : attr.checked(''))),
dom.td(
headersElem=dom.span((sel.HeadersEffective || []).join('; ')), ' ',
dom.a(attr.href(''), 'Edit', function click(e: MouseEvent) {
e.preventDefault()
popupDKIMHeaders(sel, headersElem)
}),
),
dom.td(lifetime=dom.input(attr.value(sel.Expiration))),
dom.td(dom.clickbutton('Remove', async function click(e: MouseEvent) {
if (!window.confirm('Are you sure you want to remove this selector? It is removed immediately, after which the page is reloaded, losing unsaved changes.')) {
return
}
await check(e.target! as HTMLButtonElement, client.DomainDKIMRemove(d, selName))
window.alert("Don't forget to remove the corresponding DNS records (if it exists). If the DKIM key was active, it is best to wait for all messages in transit have been delivered (which can take days if messages are held up in remote queues), or those messages will not pass DKIM validiation.")
window.location.reload() // todo: reload less
})),
)
return {
root: tr,
gather: () => {
const nsel: api.Selector = {
Hash: hash.value,
HashEffective: hash.value,
Canonicalization: {
HeaderRelaxed: canonHeader.value === 'relaxed',
BodyRelaxed: canonBody.value === 'relaxed',
},
Headers: sel.HeadersEffective,
HeadersEffective: sel.HeadersEffective,
DontSealHeaders: !seal.checked,
Expiration: lifetime.value,
PrivateKeyFile: '',
Algorithm: '',
}
return [selName, enabled.checked, nsel]
},
}
}),
),
dom.tfoot(
dom.tr(
dom.td(
attr.colspan('9'),
dom.submitbutton('Save'),
' ',
dom.clickbutton('Add key/selector', function click() {
popupDKIMAdd()
}),
),
),
),
),
),
)
})(),
dom.br(),
dom.h2('External checks'), dom.h2('External checks'),
dom.ul( dom.ul(
dom.li(link('https://internet.nl/mail/'+dnsdomain.ASCII+'/', 'Check configuration at internet.nl')), dom.li(link('https://internet.nl/mail/'+dnsdomain.ASCII+'/', 'Check configuration at internet.nl')),
), ),
dom.br(), dom.br(),
dom.h2('Danger'), dom.h2('Danger'),
dom.clickbutton('Remove domain', async function click(e: MouseEvent) { dom.clickbutton('Remove domain', async function click(e: MouseEvent) {
e.preventDefault() e.preventDefault()

View file

@ -26,6 +26,7 @@ import (
"github.com/mjl-/mox/dns" "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/mtasts"
"github.com/mjl-/mox/queue" "github.com/mjl-/mox/queue"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth" "github.com/mjl-/mox/webauth"
@ -213,6 +214,7 @@ func TestAdminAuth(t *testing.T) {
func TestAdmin(t *testing.T) { func TestAdmin(t *testing.T) {
os.RemoveAll("../testdata/webadmin/data") os.RemoveAll("../testdata/webadmin/data")
defer os.RemoveAll("../testdata/webadmin/dkim")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
@ -257,6 +259,68 @@ func TestAdmin(t *testing.T) {
api.RoutesSave(ctxbg, []config.Route{{Transport: "direct"}}) api.RoutesSave(ctxbg, []config.Route{{Transport: "direct"}})
tneedErrorCode(t, "user:error", func() { api.RoutesSave(ctxbg, []config.Route{{Transport: "bogus"}}) }) tneedErrorCode(t, "user:error", func() { api.RoutesSave(ctxbg, []config.Route{{Transport: "bogus"}}) })
api.RoutesSave(ctxbg, nil) api.RoutesSave(ctxbg, nil)
api.DomainDescriptionSave(ctxbg, "mox.example", "description")
tneedErrorCode(t, "server:error", func() { api.DomainDescriptionSave(ctxbg, "mox.example", "newline not ok\n") }) // todo: user error
tneedErrorCode(t, "user:error", func() { api.DomainDescriptionSave(ctxbg, "bogus.example", "unknown domain") })
api.DomainDescriptionSave(ctxbg, "mox.example", "") // Restore.
api.DomainClientSettingsDomainSave(ctxbg, "mox.example", "mail.mox.example")
tneedErrorCode(t, "user:error", func() { api.DomainClientSettingsDomainSave(ctxbg, "mox.example", "bogus domain") })
tneedErrorCode(t, "user:error", func() { api.DomainClientSettingsDomainSave(ctxbg, "bogus.example", "unknown.example") })
api.DomainClientSettingsDomainSave(ctxbg, "mox.example", "") // Restore.
api.DomainLocalpartConfigSave(ctxbg, "mox.example", "-", true)
tneedErrorCode(t, "user:error", func() { api.DomainLocalpartConfigSave(ctxbg, "bogus.example", "", false) })
api.DomainLocalpartConfigSave(ctxbg, "mox.example", "", false) // Restore.
api.DomainDMARCAddressSave(ctxbg, "mox.example", "dmarc-reports", "", "mjl", "DMARC")
tneedErrorCode(t, "user:error", func() { api.DomainDMARCAddressSave(ctxbg, "bogus.example", "dmarc-reports", "", "mjl", "DMARC") })
tneedErrorCode(t, "user:error", func() { api.DomainDMARCAddressSave(ctxbg, "mox.example", "dmarc-reports", "", "bogus", "DMARC") })
api.DomainDMARCAddressSave(ctxbg, "mox.example", "", "", "", "") // Restore.
api.DomainTLSRPTAddressSave(ctxbg, "mox.example", "tls-reports", "", "mjl", "TLSRPT")
tneedErrorCode(t, "user:error", func() { api.DomainTLSRPTAddressSave(ctxbg, "bogus.example", "tls-reports", "", "mjl", "TLSRPT") })
tneedErrorCode(t, "user:error", func() { api.DomainTLSRPTAddressSave(ctxbg, "mox.example", "tls-reports", "", "bogus", "TLSRPT") })
api.DomainTLSRPTAddressSave(ctxbg, "mox.example", "", "", "", "") // Restore.
// todo: cannot enable mta-sts because we have no listener, which would require a tls cert for the domain.
// api.DomainMTASTSSave(ctxbg, "mox.example", "id0", mtasts.ModeEnforce, time.Hour, []string{"mail.mox.example"})
tneedErrorCode(t, "user:error", func() {
api.DomainMTASTSSave(ctxbg, "bogus.example", "id0", mtasts.ModeEnforce, time.Hour, []string{"mail.mox.example"})
})
tneedErrorCode(t, "user:error", func() {
api.DomainMTASTSSave(ctxbg, "mox.example", "invalid id", mtasts.ModeEnforce, time.Hour, []string{"mail.mox.example"})
})
tneedErrorCode(t, "user:error", func() {
api.DomainMTASTSSave(ctxbg, "mox.example", "id0", mtasts.Mode("bogus"), time.Hour, []string{"mail.mox.example"})
})
tneedErrorCode(t, "user:error", func() {
api.DomainMTASTSSave(ctxbg, "mox.example", "id0", mtasts.ModeEnforce, time.Hour, []string{"*.*.mail.mox.example"})
})
api.DomainMTASTSSave(ctxbg, "mox.example", "", mtasts.ModeNone, 0, nil) // Restore.
api.DomainDKIMAdd(ctxbg, "mox.example", "testsel", "ed25519", "sha256", true, true, true, nil, 24*time.Hour)
tneedErrorCode(t, "user:error", func() {
api.DomainDKIMAdd(ctxbg, "mox.example", "testsel", "ed25519", "sha256", true, true, true, nil, 24*time.Hour)
}) // Duplicate selector.
tneedErrorCode(t, "user:error", func() {
api.DomainDKIMAdd(ctxbg, "bogus.example", "testsel", "ed25519", "sha256", true, true, true, nil, 24*time.Hour)
})
conf := api.DomainConfig(ctxbg, "mox.example")
api.DomainDKIMSave(ctxbg, "mox.example", conf.DKIM.Selectors, conf.DKIM.Sign)
api.DomainDKIMSave(ctxbg, "mox.example", conf.DKIM.Selectors, []string{"testsel"})
tneedErrorCode(t, "user:error", func() { api.DomainDKIMSave(ctxbg, "mox.example", conf.DKIM.Selectors, []string{"bogus"}) })
tneedErrorCode(t, "user:error", func() { api.DomainDKIMSave(ctxbg, "mox.example", nil, []string{}) }) // Cannot remove selectors with save.
tneedErrorCode(t, "user:error", func() { api.DomainDKIMSave(ctxbg, "bogus.example", nil, []string{}) })
moreSel := map[string]config.Selector{
"testsel": conf.DKIM.Selectors["testsel"],
"testsel2": conf.DKIM.Selectors["testsel2"],
}
tneedErrorCode(t, "user:error", func() { api.DomainDKIMSave(ctxbg, "mox.example", moreSel, []string{}) }) // Cannot add selectors with save.
api.DomainDKIMRemove(ctxbg, "mox.example", "testsel")
tneedErrorCode(t, "user:error", func() { api.DomainDKIMRemove(ctxbg, "mox.example", "testsel") }) // Already removed.
tneedErrorCode(t, "user:error", func() { api.DomainDKIMRemove(ctxbg, "bogus.example", "testsel") })
} }
func TestCheckDomain(t *testing.T) { func TestCheckDomain(t *testing.T) {

View file

@ -1615,6 +1615,289 @@
} }
], ],
"Returns": [] "Returns": []
},
{
"Name": "DomainDescriptionSave",
"Docs": "DomainDescriptionSave saves the description for a domain.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "descr",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainClientSettingsDomainSave",
"Docs": "DomainClientSettingsDomainSave saves the client settings domain for a domain.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "clientSettingsDomain",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainLocalpartConfigSave",
"Docs": "DomainLocalpartConfigSave saves the localpart catchall and case-sensitive\nsettings for a domain.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "localpartCatchallSeparator",
"Typewords": [
"string"
]
},
{
"Name": "localpartCaseSensitive",
"Typewords": [
"bool"
]
}
],
"Returns": []
},
{
"Name": "DomainDMARCAddressSave",
"Docs": "DomainDMARCAddressSave saves the DMARC reporting address/processing\nconfiguration for a domain. If localpart is empty, processing reports is\ndisabled.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "localpart",
"Typewords": [
"string"
]
},
{
"Name": "domain",
"Typewords": [
"string"
]
},
{
"Name": "account",
"Typewords": [
"string"
]
},
{
"Name": "mailbox",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainTLSRPTAddressSave",
"Docs": "DomainTLSRPTAddressSave saves the TLS reporting address/processing\nconfiguration for a domain. If localpart is empty, processing reports is\ndisabled.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "localpart",
"Typewords": [
"string"
]
},
{
"Name": "domain",
"Typewords": [
"string"
]
},
{
"Name": "account",
"Typewords": [
"string"
]
},
{
"Name": "mailbox",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainMTASTSSave",
"Docs": "DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,\nno MTASTS policy is served.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "policyID",
"Typewords": [
"string"
]
},
{
"Name": "mode",
"Typewords": [
"Mode"
]
},
{
"Name": "maxAge",
"Typewords": [
"int64"
]
},
{
"Name": "mx",
"Typewords": [
"[]",
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainDKIMAdd",
"Docs": "DomainDKIMAdd adds a DKIM selector for a domain, generating a new private\nkey. The selector is not enabled for signing.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "selector",
"Typewords": [
"string"
]
},
{
"Name": "algorithm",
"Typewords": [
"string"
]
},
{
"Name": "hash",
"Typewords": [
"string"
]
},
{
"Name": "headerRelaxed",
"Typewords": [
"bool"
]
},
{
"Name": "bodyRelaxed",
"Typewords": [
"bool"
]
},
{
"Name": "seal",
"Typewords": [
"bool"
]
},
{
"Name": "headers",
"Typewords": [
"[]",
"string"
]
},
{
"Name": "lifetime",
"Typewords": [
"int64"
]
}
],
"Returns": []
},
{
"Name": "DomainDKIMRemove",
"Docs": "DomainDKIMRemove removes a DKIM selector for a domain.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "selector",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{
"Name": "DomainDKIMSave",
"Docs": "DomainDKIMSave saves the settings of selectors, and which to enable for\nsigning, for a domain. All currently configured selectors must be present,\nselectors cannot be added/removed with this function.",
"Params": [
{
"Name": "domainName",
"Typewords": [
"string"
]
},
{
"Name": "selectors",
"Typewords": [
"{}",
"Selector"
]
},
{
"Name": "sign",
"Typewords": [
"[]",
"string"
]
}
],
"Returns": []
} }
], ],
"Sections": [], "Sections": [],
@ -3037,6 +3320,13 @@
"Typewords": [ "Typewords": [
"string" "string"
] ]
},
{
"Name": "Algorithm",
"Docs": "\"ed25519\", \"rsa-*\", based on private key.",
"Typewords": [
"string"
]
} }
] ]
}, },

View file

@ -292,6 +292,7 @@ export interface Selector {
DontSealHeaders: boolean DontSealHeaders: boolean
Expiration: string Expiration: string
PrivateKeyFile: string PrivateKeyFile: string
Algorithm: string // "ed25519", "rsa-*", based on private key.
} }
export interface Canonicalization { export interface Canonicalization {
@ -1183,7 +1184,7 @@ export const types: TypenameMap = {
"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"]}]},
"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"]}]}, "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"]}]},
"DMARC": {"Name":"DMARC","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"]}]}, "DMARC": {"Name":"DMARC","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"]}]},
"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"]}]},
@ -2163,6 +2164,96 @@ export class Client {
const params: any[] = [routes] const params: any[] = [routes]
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
} }
// DomainDescriptionSave saves the description for a domain.
async DomainDescriptionSave(domainName: string, descr: string): Promise<void> {
const fn: string = "DomainDescriptionSave"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, descr]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainClientSettingsDomainSave saves the client settings domain for a domain.
async DomainClientSettingsDomainSave(domainName: string, clientSettingsDomain: string): Promise<void> {
const fn: string = "DomainClientSettingsDomainSave"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, clientSettingsDomain]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain.
async DomainLocalpartConfigSave(domainName: string, localpartCatchallSeparator: string, localpartCaseSensitive: boolean): Promise<void> {
const fn: string = "DomainLocalpartConfigSave"
const paramTypes: string[][] = [["string"],["string"],["bool"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, localpartCatchallSeparator, localpartCaseSensitive]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainDMARCAddressSave saves the DMARC reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
async DomainDMARCAddressSave(domainName: string, localpart: string, domain: string, account: string, mailbox: string): Promise<void> {
const fn: string = "DomainDMARCAddressSave"
const paramTypes: string[][] = [["string"],["string"],["string"],["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, localpart, domain, account, mailbox]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainTLSRPTAddressSave saves the TLS reporting address/processing
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
async DomainTLSRPTAddressSave(domainName: string, localpart: string, domain: string, account: string, mailbox: string): Promise<void> {
const fn: string = "DomainTLSRPTAddressSave"
const paramTypes: string[][] = [["string"],["string"],["string"],["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, localpart, domain, account, mailbox]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served.
async DomainMTASTSSave(domainName: string, policyID: string, mode: Mode, maxAge: number, mx: string[] | null): Promise<void> {
const fn: string = "DomainMTASTSSave"
const paramTypes: string[][] = [["string"],["string"],["Mode"],["int64"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, policyID, mode, maxAge, mx]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainDKIMAdd adds a DKIM selector for a domain, generating a new private
// key. The selector is not enabled for signing.
async DomainDKIMAdd(domainName: string, selector: string, algorithm: string, hash: string, headerRelaxed: boolean, bodyRelaxed: boolean, seal: boolean, headers: string[] | null, lifetime: number): Promise<void> {
const fn: string = "DomainDKIMAdd"
const paramTypes: string[][] = [["string"],["string"],["string"],["string"],["bool"],["bool"],["bool"],["[]","string"],["int64"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, selector, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainDKIMRemove removes a DKIM selector for a domain.
async DomainDKIMRemove(domainName: string, selector: string): Promise<void> {
const fn: string = "DomainDKIMRemove"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, selector]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainDKIMSave saves the settings of selectors, and which to enable for
// signing, for a domain. All currently configured selectors must be present,
// selectors cannot be added/removed with this function.
async DomainDKIMSave(domainName: string, selectors: { [key: string]: Selector }, sign: string[] | null): Promise<void> {
const fn: string = "DomainDKIMSave"
const paramTypes: string[][] = [["string"],["{}","Selector"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [domainName, selectors, sign]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
} }
export const defaultBaseURL = (function() { export const defaultBaseURL = (function() {