mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 08:23:48 +03:00
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:
parent
a69887bfab
commit
e702f45d32
11 changed files with 1836 additions and 100 deletions
2
Makefile
2
Makefile
|
@ -107,7 +107,7 @@ fmt:
|
|||
go fmt ./...
|
||||
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'
|
||||
|
||||
install-js:
|
||||
|
|
|
@ -279,7 +279,7 @@ type Domain struct {
|
|||
LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."`
|
||||
DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."`
|
||||
DMARC *DMARC `sconf:"optional" sconf-doc:"With DMARC, a domain publishes, in DNS, a policy on how other mail servers should handle incoming messages with the From-header matching this domain and/or subdomain (depending on the configured alignment). Receiving mail servers use this to build up a reputation of this domain, which can help with mail delivery. A domain can also publish an email address to which reports about DMARC verification results can be sent by verifying mail servers, useful for monitoring. Incoming DMARC reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."`
|
||||
MTASTS *MTASTS `sconf:"optional" sconf-doc:"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."`
|
||||
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 {
|
||||
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."`
|
||||
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. DMARC."`
|
||||
|
||||
|
@ -303,8 +303,8 @@ type DMARC 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."`
|
||||
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."`
|
||||
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:"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."`
|
||||
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
|
||||
|
@ -312,7 +312,7 @@ type MTASTS struct {
|
|||
|
||||
type TLSRPT struct {
|
||||
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."`
|
||||
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. TLSRPT."`
|
||||
|
||||
|
@ -326,7 +326,7 @@ type Canonicalization 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:"-"`
|
||||
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."`
|
||||
|
@ -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."`
|
||||
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.
|
||||
Key crypto.Signer `sconf:"-" json:"-"` // As parsed with x509.ParsePKCS8PrivateKey.
|
||||
Domain dns.Domain `sconf:"-" json:"-"` // Of selector only, not FQDN.
|
||||
|
|
|
@ -752,7 +752,7 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
|
|||
Selectors:
|
||||
x:
|
||||
|
||||
# sha256 (default) or (older, not recommended) sha1 (optional)
|
||||
# sha256 (default) or (older, not recommended) sha1. (optional)
|
||||
Hash:
|
||||
|
||||
# (optional)
|
||||
|
@ -800,8 +800,15 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
|
|||
# non-internationalized. Recommended value: dmarc-reports.
|
||||
Localpart:
|
||||
|
||||
# Alternative domain for report recipient address. Can be used to receive reports
|
||||
# for other domains. Unicode name. (optional)
|
||||
# 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. (optional)
|
||||
Domain:
|
||||
|
||||
# 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:
|
||||
|
||||
# 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.
|
||||
# (optional)
|
||||
# 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. (optional)
|
||||
MTASTS:
|
||||
|
||||
# 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:
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
Mode:
|
||||
|
||||
# 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.
|
||||
Localpart:
|
||||
|
||||
# Alternative domain for report recipient address. Can be used to receive reports
|
||||
# for other domains. Unicode name. (optional)
|
||||
# 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. (optional)
|
||||
Domain:
|
||||
|
||||
# Account to deliver to.
|
||||
|
|
289
mox-/admin.go
289
mox-/admin.go
|
@ -10,6 +10,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
|
@ -29,11 +30,14 @@ import (
|
|||
"github.com/mjl-/mox/dmarc"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/junk"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mtasts"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"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
|
||||
// 100 characters. In case of multiple strings, a multi-line record is returned.
|
||||
func TXTStrings(s string) string {
|
||||
|
@ -151,6 +155,31 @@ func MakeAccountConfig(addr smtp.Address) config.Account {
|
|||
return account
|
||||
}
|
||||
|
||||
func writeFile(log mlog.Log, path string, data []byte) error {
|
||||
os.MkdirAll(filepath.Dir(path), 0770)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file %s: %s", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if f != nil {
|
||||
err := f.Close()
|
||||
log.Check(err, "closing file after error")
|
||||
err = os.Remove(path)
|
||||
log.Check(err, "removing file after error", slog.String("path", path))
|
||||
}
|
||||
}()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fmt.Errorf("writing file %s: %s", path, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close file: %v", err)
|
||||
}
|
||||
f = 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) {
|
||||
|
@ -168,31 +197,6 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
|
|||
}
|
||||
}()
|
||||
|
||||
writeFile := func(path string, data []byte) error {
|
||||
os.MkdirAll(filepath.Dir(path), 0770)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file %s: %s", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if f != nil {
|
||||
err := f.Close()
|
||||
log.Check(err, "closing file after error")
|
||||
err = os.Remove(path)
|
||||
log.Check(err, "removing file after error", slog.String("path", path))
|
||||
}
|
||||
}()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fmt.Errorf("writing file %s: %s", path, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close file: %v", err)
|
||||
}
|
||||
f = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
confDKIM := config.DKIM{
|
||||
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)
|
||||
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
|
||||
p := configDirPath(ConfigDynamicPath, keyPath)
|
||||
if err := writeFile(p, privKey); err != nil {
|
||||
if err := writeFile(log, p, privKey); err != nil {
|
||||
return err
|
||||
}
|
||||
paths = append(paths, p)
|
||||
|
@ -282,6 +286,164 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
|
|||
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
|
||||
// marking it loaded.
|
||||
//
|
||||
|
@ -304,7 +466,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
|
|||
|
||||
c := Conf.Dynamic
|
||||
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
|
||||
|
@ -336,11 +498,11 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
|
|||
}()
|
||||
|
||||
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 == "" {
|
||||
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 == "" {
|
||||
return fmt.Errorf("account name is empty")
|
||||
return fmt.Errorf("%w: account name is empty", ErrRequest)
|
||||
} else if !ok {
|
||||
nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain})
|
||||
} 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
|
||||
|
||||
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))
|
||||
cleanupFiles = nil // All good, don't cleanup.
|
||||
|
@ -382,7 +544,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
|
|||
c := Conf.Dynamic
|
||||
domConf, ok := c.Domains[domain.Name()]
|
||||
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
|
||||
|
@ -397,18 +559,30 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
|
|||
}
|
||||
|
||||
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
|
||||
// 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{}
|
||||
for _, dc := range nc.Domains {
|
||||
for _, sel := range dc.DKIM.Selectors {
|
||||
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)] {
|
||||
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.Info("domain removed", slog.Any("domain", domain))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
dom, ok := nc.Domains[domainName] // dom is a shallow copy.
|
||||
if !ok {
|
||||
return fmt.Errorf("domain not present")
|
||||
return fmt.Errorf("%w: domain not present", ErrRequest)
|
||||
}
|
||||
|
||||
xmodify(&dom)
|
||||
|
@ -789,7 +960,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
|
|||
|
||||
addr, err := smtp.ParseAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing email address: %v", err)
|
||||
return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
|
||||
}
|
||||
|
||||
Conf.dynamicMutex.Lock()
|
||||
|
@ -797,11 +968,11 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
|
|||
|
||||
c := Conf.Dynamic
|
||||
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 {
|
||||
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
|
||||
|
@ -834,7 +1005,7 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
|
|||
|
||||
c := Conf.Dynamic
|
||||
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
|
||||
|
@ -888,30 +1059,30 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
|
|||
c := Conf.Dynamic
|
||||
a, ok := c.Accounts[account]
|
||||
if !ok {
|
||||
return fmt.Errorf("account does not exist")
|
||||
return fmt.Errorf("%w: account does not exist", ErrRequest)
|
||||
}
|
||||
|
||||
var destAddr string
|
||||
if strings.HasPrefix(address, "@") {
|
||||
d, err := dns.ParseDomain(address[1:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing domain: %v", err)
|
||||
return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err)
|
||||
}
|
||||
dname := d.Name()
|
||||
destAddr = "@" + dname
|
||||
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 {
|
||||
return fmt.Errorf("catchall address already configured for domain")
|
||||
return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
|
||||
}
|
||||
} else {
|
||||
addr, err := smtp.ParseAddress(address)
|
||||
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 {
|
||||
return fmt.Errorf("address not available: %v", err)
|
||||
return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
|
||||
}
|
||||
destAddr = addr.String()
|
||||
}
|
||||
|
@ -953,7 +1124,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
|
|||
|
||||
ad, ok := Conf.accountDestinations[address]
|
||||
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
|
||||
|
@ -973,7 +1144,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
|
|||
}
|
||||
}
|
||||
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.
|
||||
|
@ -984,12 +1155,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
|
|||
if strings.HasPrefix(address, "@") {
|
||||
dom, err = dns.ParseDomain(address[1:])
|
||||
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 {
|
||||
pa, err = smtp.ParseAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing address: %v", err)
|
||||
return fmt.Errorf("%w: parsing address: %v", ErrRequest, err)
|
||||
}
|
||||
dom = pa.Domain
|
||||
}
|
||||
|
@ -1004,15 +1175,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
|
|||
}
|
||||
dc, ok := Conf.Dynamic.Domains[dom.Name()]
|
||||
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)
|
||||
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)
|
||||
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 {
|
||||
// Keep for different localpart.
|
||||
|
@ -1054,7 +1225,7 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
|
|||
c := Conf.Dynamic
|
||||
acc, ok := c.Accounts[account]
|
||||
if !ok {
|
||||
return fmt.Errorf("account not present")
|
||||
return fmt.Errorf("%w: account not present", ErrRequest)
|
||||
}
|
||||
|
||||
xmodify(&acc)
|
||||
|
@ -1101,7 +1272,7 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
|
|||
|
||||
domConf, ok := Conf.Domain(d)
|
||||
if !ok {
|
||||
return ClientConfig{}, fmt.Errorf("unknown domain")
|
||||
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
|
||||
}
|
||||
|
||||
gather := func(l config.Listener) (done bool) {
|
||||
|
@ -1159,7 +1330,7 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
|
|||
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
|
||||
|
@ -1181,7 +1352,7 @@ type ClientConfigsEntry struct {
|
|||
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
|
||||
domConf, ok := Conf.Domain(d)
|
||||
if !ok {
|
||||
return ClientConfigs{}, fmt.Errorf("unknown domain")
|
||||
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
|
||||
}
|
||||
|
||||
c := ClientConfigs{}
|
||||
|
|
|
@ -1176,11 +1176,13 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
|
|||
addErrorf("rsa keys should be >= 1024 bits")
|
||||
}
|
||||
sel.Key = k
|
||||
sel.Algorithm = fmt.Sprintf("rsa-%d", k.N.BitLen())
|
||||
case ed25519.PrivateKey:
|
||||
if sel.HashEffective != "sha256" {
|
||||
addErrorf("hash algorithm %q is not supported with ed25519, only sha256 is", sel.HashEffective)
|
||||
}
|
||||
sel.Key = k
|
||||
sel.Algorithm = "ed25519"
|
||||
default:
|
||||
addErrorf("private key type %T not yet supported, at selector %s in domain %s", key, name, d)
|
||||
}
|
||||
|
|
|
@ -194,8 +194,8 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
|
|||
if err == nil {
|
||||
return
|
||||
}
|
||||
// If caller tried saving a config that is invalid, cause a user error.
|
||||
if errors.Is(err, mox.ErrConfig) {
|
||||
// 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) || errors.Is(err, mox.ErrRequest) {
|
||||
xcheckuserf(ctx, err, format, args...)
|
||||
}
|
||||
|
||||
|
@ -2443,3 +2443,154 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
|
|||
})
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -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"] }] },
|
||||
"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"] }] },
|
||||
"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"] }] },
|
||||
"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"] }] },
|
||||
|
@ -1270,6 +1270,87 @@ var api;
|
|||
const params = [routes];
|
||||
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.defaultBaseURL = (function () {
|
||||
|
@ -2175,6 +2256,59 @@ const account = async (name) => {
|
|||
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 end = new Date();
|
||||
const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000);
|
||||
|
@ -2188,10 +2322,72 @@ const domain = async (d) => {
|
|||
client.DomainConfig(d),
|
||||
client.Transports(),
|
||||
]);
|
||||
let form;
|
||||
let fieldset;
|
||||
let localpart;
|
||||
let account;
|
||||
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 selector;
|
||||
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) {
|
||||
e.preventDefault();
|
||||
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));
|
||||
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.stopPropagation();
|
||||
await check(fieldset, client.AddressAdd(localpart.value + '@' + d, account.value));
|
||||
form.reset();
|
||||
await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value));
|
||||
addrForm.reset();
|
||||
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();
|
||||
if (!window.confirm('Are you sure you want to remove this domain?')) {
|
||||
return;
|
||||
|
|
|
@ -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 end = new Date()
|
||||
const start = new Date(new Date().getTime() - 30*24*3600*1000)
|
||||
|
@ -986,10 +1020,164 @@ const domain = async (d: string) => {
|
|||
client.Transports(),
|
||||
])
|
||||
|
||||
let form: HTMLFormElement
|
||||
let fieldset: HTMLFieldSetElement
|
||||
let localpart: HTMLInputElement
|
||||
let account: HTMLSelectElement
|
||||
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 selector: HTMLInputElement
|
||||
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,
|
||||
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.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(
|
||||
|
@ -1022,12 +1211,15 @@ const domain = async (d: string) => {
|
|||
),
|
||||
),
|
||||
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(
|
||||
|
@ -1055,21 +1247,22 @@ const domain = async (d: string) => {
|
|||
),
|
||||
),
|
||||
dom.br(),
|
||||
|
||||
dom.h2('Add address'),
|
||||
form=dom.form(
|
||||
addrForm=dom.form(
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await check(fieldset, client.AddressAdd(localpart.value+'@'+d, account.value))
|
||||
form.reset()
|
||||
await check(addrFieldset, client.AddressAdd(addrLocalpart.value+'@'+d, addrAccount.value))
|
||||
addrForm.reset()
|
||||
window.location.reload() // todo: only reload the addresses
|
||||
},
|
||||
fieldset=dom.fieldset(
|
||||
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(),
|
||||
localpart=dom.input(),
|
||||
addrLocalpart=dom.input(),
|
||||
),
|
||||
'@', domainName(dnsdomain),
|
||||
' ',
|
||||
|
@ -1077,20 +1270,404 @@ const domain = async (d: string) => {
|
|||
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))),
|
||||
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: api.Route[]) => await client.DomainRoutesSave(d, routes)),
|
||||
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.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: MouseEvent) {
|
||||
e.preventDefault()
|
||||
|
@ -3284,7 +3861,7 @@ const hooksList = async () => {
|
|||
sort.Last = last.NextAttempt
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tbody.classList.add('loadstart')
|
||||
const l = await check(e.target! as HTMLButtonElement, client.HookList(filter, sort)) || []
|
||||
hooks.push(...l)
|
||||
|
|
|
@ -26,6 +26,7 @@ import (
|
|||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/mtasts"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/store"
|
||||
"github.com/mjl-/mox/webauth"
|
||||
|
@ -213,6 +214,7 @@ func TestAdminAuth(t *testing.T) {
|
|||
|
||||
func TestAdmin(t *testing.T) {
|
||||
os.RemoveAll("../testdata/webadmin/data")
|
||||
defer os.RemoveAll("../testdata/webadmin/dkim")
|
||||
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf")
|
||||
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
|
||||
mox.MustLoadConfig(true, false)
|
||||
|
@ -257,6 +259,68 @@ func TestAdmin(t *testing.T) {
|
|||
api.RoutesSave(ctxbg, []config.Route{{Transport: "direct"}})
|
||||
tneedErrorCode(t, "user:error", func() { api.RoutesSave(ctxbg, []config.Route{{Transport: "bogus"}}) })
|
||||
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) {
|
||||
|
|
|
@ -1615,6 +1615,289 @@
|
|||
}
|
||||
],
|
||||
"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": [],
|
||||
|
@ -3037,6 +3320,13 @@
|
|||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "Algorithm",
|
||||
"Docs": "\"ed25519\", \"rsa-*\", based on private key.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -292,6 +292,7 @@ export interface Selector {
|
|||
DontSealHeaders: boolean
|
||||
Expiration: string
|
||||
PrivateKeyFile: string
|
||||
Algorithm: string // "ed25519", "rsa-*", based on private key.
|
||||
}
|
||||
|
||||
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"]}]},
|
||||
"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"]}]},
|
||||
"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"]}]},
|
||||
"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"]}]},
|
||||
|
@ -2163,6 +2164,96 @@ export class Client {
|
|||
const params: any[] = [routes]
|
||||
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() {
|
||||
|
|
Loading…
Reference in a new issue