move config-changing code from package mox-/ to admin/

needed for upcoming changes, where (now) package admin needs to import package
store. before, because package store imports mox- (for accessing the active
config), that would lead to a cyclic import. package mox- keeps its active
config, package admin has the higher-level config-changing functions.
This commit is contained in:
Mechiel Lukkien 2024-12-02 22:03:18 +01:00
parent de435fceba
commit 5f7831a7f0
No known key found for this signature in database
18 changed files with 805 additions and 756 deletions

View file

@ -1,68 +1,37 @@
package mox package admin
import ( import (
"bytes" "bytes"
"context" "context"
"crypto"
"crypto/ed25519" "crypto/ed25519"
cryptorand "crypto/rand" cryptorand "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
"time" "time"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"github.com/mjl-/adns"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk" "github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/tlsrpt"
) )
var pkglog = mlog.New("admin", nil)
var ErrRequest = errors.New("bad request") 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 {
if len(s) <= 100 {
return `"` + s + `"`
}
r := "(\n"
for len(s) > 0 {
n := len(s)
if n > 100 {
n = 100
}
if r != "" {
r += " "
}
r += "\t\t\"" + s[:n] + "\"\n"
s = s[n:]
}
r += "\t)"
return r
}
// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use // MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
// with DKIM. // with DKIM.
// selector and domain can be empty. If not, they are used in the note. // selector and domain can be empty. If not, they are used in the note.
@ -206,7 +175,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
addSelector := func(kind, name string, privKey []byte) error { addSelector := func(kind, name string, privKey []byte) error {
record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII) record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath) p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil { if err := writeFile(log, p, privKey); err != nil {
return err return err
} }
@ -323,10 +292,9 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
} }
// Only take lock now, we don't want to hold it while generating a key. // Only take lock now, we don't want to hold it while generating a key.
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()] d, ok := c.Domains[domain.Name()]
if !ok { if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest) return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -339,7 +307,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII) record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
timestamp := time.Now().Format("20060102T150405") timestamp := time.Now().Format("20060102T150405")
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath) p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil { if err := writeFile(log, p, privKey); err != nil {
return fmt.Errorf("writing key file: %v", err) return fmt.Errorf("writing key file: %v", err)
} }
@ -377,7 +345,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
} }
nc.Domains[domain.Name()] = nd nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
@ -397,10 +365,9 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()] d, ok := c.Domains[domain.Name()]
if !ok { if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest) return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -433,7 +400,7 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
} }
nc.Domains[domain.Name()] = nd nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
@ -463,10 +430,9 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
if _, ok := c.Domains[domain.Name()]; ok { if _, ok := c.Domains[domain.Name()]; ok {
return fmt.Errorf("%w: domain already present", ErrRequest) return fmt.Errorf("%w: domain already present", ErrRequest)
} }
@ -481,14 +447,14 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
// Only enable mta-sts for domain if there is a listener with mta-sts. // Only enable mta-sts for domain if there is a listener with mta-sts.
var withMTASTS bool var withMTASTS bool
for _, l := range Conf.Static.Listeners { for _, l := range mox.Conf.Static.Listeners {
if l.MTASTSHTTPS.Enabled { if l.MTASTSHTTPS.Enabled {
withMTASTS = true withMTASTS = true
break break
} }
} }
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS) confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS)
if err != nil { if err != nil {
return fmt.Errorf("preparing domain config: %v", err) return fmt.Errorf("preparing domain config: %v", err)
} }
@ -507,7 +473,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
return fmt.Errorf("%w: account name is empty", ErrRequest) return fmt.Errorf("%w: account name is empty", ErrRequest)
} else if !ok { } else if !ok {
nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain)) nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain))
} else if accountName != Conf.Static.Postmaster.Account { } else if accountName != mox.Conf.Static.Postmaster.Account {
nacc := nc.Accounts[accountName] nacc := nc.Accounts[accountName]
nd := map[string]config.Destination{} nd := map[string]config.Destination{}
for k, v := range nacc.Destinations { for k, v := range nacc.Destinations {
@ -521,7 +487,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
nc.Domains[domain.Name()] = confDomain nc.Domains[domain.Name()] = confDomain
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("domain added", slog.Any("domain", domain)) log.Info("domain added", slog.Any("domain", domain))
@ -540,10 +506,9 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
domConf, ok := c.Domains[domain.Name()] domConf, ok := c.Domains[domain.Name()]
if !ok { if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest) return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -560,7 +525,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
} }
} }
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
@ -588,8 +553,8 @@ func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths ma
if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] { if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
continue continue
} }
src := ConfigDirPath(sel.PrivateKeyFile) src := mox.ConfigDirPath(sel.PrivateKeyFile)
dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile))) dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
_, err := os.Stat(dst) _, err := os.Stat(dst)
if err == nil { if err == nil {
err = fmt.Errorf("destination already exists") err = fmt.Errorf("destination already exists")
@ -615,10 +580,9 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
nc := Conf.Dynamic // Shallow copy. nc := mox.Conf.Dynamic // Shallow copy.
dom, ok := nc.Domains[domainName] // dom is a shallow copy. dom, ok := nc.Domains[domainName] // dom is a shallow copy.
if !ok { if !ok {
return fmt.Errorf("%w: domain not present", ErrRequest) return fmt.Errorf("%w: domain not present", ErrRequest)
@ -631,12 +595,12 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
// leave no trace. // leave no trace.
nc.Domains = map[string]config.Domain{} nc.Domains = map[string]config.Domain{}
for name, d := range Conf.Dynamic.Domains { for name, d := range mox.Conf.Dynamic.Domains {
nc.Domains[name] = d nc.Domains[name] = d
} }
nc.Domains[domainName] = dom nc.Domains[domainName] = dom
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
@ -656,13 +620,12 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
nc := Conf.Dynamic // Shallow copy. nc := mox.Conf.Dynamic // Shallow copy.
xmodify(&nc) xmodify(&nc)
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
@ -670,330 +633,6 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
return nil return nil
} }
// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
// transports.
func DomainSPFIPs() (ips []net.IP) {
for _, l := range Conf.Static.Listeners {
if !l.SMTP.Enabled || l.IPsNATed {
continue
}
ipstrs := l.IPs
if len(l.NATIPs) > 0 {
ipstrs = l.NATIPs
}
for _, ipstr := range ipstrs {
ip := net.ParseIP(ipstr)
if ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips
}
// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
// DomainRecords returns text lines describing DNS records required for configuring
// a domain.
//
// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
// that caID will be suggested. If acmeAccountURI is also set, CAA records also
// restricting issuance to that account ID will be suggested.
func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
d := domain.ASCII
h := Conf.Static.HostnameDomain.ASCII
// The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
// ../testdata/integration/moxmail2.sh for selecting DNS records
records := []string{
"; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
"; Once your setup is working, you may want to increase the TTL.",
"$TTL 300",
"",
}
if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
records = append(records,
`; DANE: These records indicate that a remote mail server trying to deliver email`,
`; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
`; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
`; hexadecimal hash. DANE-EE verification means only the certificate or public`,
`; key is verified, not whether the certificate is signed by a (centralized)`,
`; certificate authority (CA), is expired, or matches the host name.`,
`;`,
`; NOTE: Create the records below only once: They are for the machine, and apply`,
`; to all hosted domains.`,
)
if !hasDNSSEC {
records = append(records,
";",
"; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
"; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
"; commented out.",
)
}
addTLSA := func(privKey crypto.Signer) error {
spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
if err != nil {
return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
}
sum := sha256.Sum256(spkiBuf)
tlsaRecord := adns.TLSA{
Usage: adns.TLSAUsageDANEEE,
Selector: adns.TLSASelectorSPKI,
MatchType: adns.TLSAMatchTypeSHA256,
CertAssoc: sum[:],
}
var s string
if hasDNSSEC {
s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
} else {
s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
}
records = append(records, s)
return nil
}
for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
records = append(records, "")
}
if d != h {
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
"; messages (DSNs) sent from host:",
fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
)
}
if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
"",
)
}
records = append(records,
"; Deliver email for the domain to this host.",
fmt.Sprintf("%s. MX 10 %s.", d, h),
"",
"; Outgoing messages will be signed with the first two DKIM keys. The other two",
"; configured for backup, switching to them is just a config change.",
)
var selectors []string
for name := range domConf.DKIM.Selectors {
selectors = append(selectors, name)
}
sort.Slice(selectors, func(i, j int) bool {
return selectors[i] < selectors[j]
})
for _, name := range selectors {
sel := domConf.DKIM.Selectors[name]
dkimr := dkim.Record{
Version: "DKIM1",
Hashes: []string{"sha256"},
PublicKey: sel.Key.Public(),
}
if _, ok := sel.Key.(ed25519.PrivateKey); ok {
dkimr.Key = "ed25519"
} else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
}
txt, err := dkimr.Record()
if err != nil {
return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
}
if len(txt) > 100 {
records = append(records,
"; NOTE: The following is a single long record split over several lines for use",
"; in zone files. When adding through a DNS operator web interface, combine the",
"; strings into a single string, without ().",
)
}
s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt))
records = append(records, s)
}
dmarcr := dmarc.DefaultRecord
dmarcr.Policy = "reject"
if domConf.DMARC != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
}
dmarcr.AggregateReportAddresses = []dmarc.URI{
{Address: uri.String(), MaxSize: 10, Unit: "m"},
}
}
dspfr := spf.Record{Version: "spf1"}
for _, ip := range DomainSPFIPs() {
mech := "ip4"
if ip.To4() == nil {
mech = "ip6"
}
dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
}
dspfr.Directives = append(dspfr.Directives,
spf.Directive{Mechanism: "mx"},
spf.Directive{Qualifier: "~", Mechanism: "all"},
)
dspftxt, err := dspfr.Record()
if err != nil {
return nil, fmt.Errorf("making domain spf record: %v", err)
}
records = append(records,
"",
"; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
"; ~all means softfail for anything else, which is done instead of -all to prevent older",
"; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
"",
"; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
"; should be rejected, and request reports. If you email through mailing lists that",
"; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
"; set the policy to p=none.",
fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; Remote servers can use MTA-STS to verify our TLS certificate with the",
"; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
"; STARTTLSTLS.",
fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
} else {
records = append(records,
"; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
"; domain or because mox.conf does not have a listener with MTA-STS configured.",
"",
)
}
if domConf.TLSRPT != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
"",
)
}
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
records = append(records,
"; Client settings will reference a subdomain of the hosted domain, making it",
"; easier to migrate to a different server in the future by not requiring settings",
"; in all clients to be updated.",
fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
"",
)
}
records = append(records,
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and non-TLS ports are not to be used.",
"; These are optional and safe to leave out (e.g. if you have to click a lot in a",
"; DNS admin web interface).",
fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
)
if certIssuerDomainName != "" {
// ../rfc/8659:18 for CAA records.
records = append(records,
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
)
if acmeAccountURI != "" {
// ../rfc/8657:99 for accounturi.
// ../rfc/8657:147 for validationmethods.
records = append(records,
";",
"; Optionally limit certificates for this domain to the account ID and methods used by mox.",
fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
";",
"; Or alternatively only limit for email-specific subdomains, so you can use",
"; other accounts/methods for other subdomains.",
fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
)
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
records = append(records,
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
)
}
if strings.HasSuffix(h, "."+d) {
records = append(records,
";",
"; And the mail hostname.",
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
)
}
} else {
// The string "will be suggested" is used by
// ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
// as end of DNS records.
records = append(records,
";",
"; Note: After starting up, once an ACME account has been created, CAA records",
"; that restrict issuance to the account will be suggested.",
)
}
}
return records, nil
}
// AccountAdd adds an account and an initial address and reloads the configuration. // AccountAdd adds an account and an initial address and reloads the configuration.
// //
// The new account does not have a password, so cannot yet log in. Email can be // The new account does not have a password, so cannot yet log in. Email can be
@ -1013,10 +652,9 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err) return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
} }
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; ok { if _, ok := c.Accounts[account]; ok {
return fmt.Errorf("%w: account already present", ErrRequest) return fmt.Errorf("%w: account already present", ErrRequest)
} }
@ -1034,7 +672,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
} }
nc.Accounts[account] = MakeAccountConfig(addr) nc.Accounts[account] = MakeAccountConfig(addr)
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("account added", slog.String("account", account), slog.Any("address", addr)) log.Info("account added", slog.String("account", account), slog.Any("address", addr))
@ -1050,10 +688,9 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; !ok { if _, ok := c.Accounts[account]; !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest) return fmt.Errorf("%w: account does not exist", ErrRequest)
} }
@ -1068,12 +705,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
} }
} }
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
odir := filepath.Join(DataDirPath("accounts"), account) odir := filepath.Join(mox.DataDirPath("accounts"), account)
tmpdir := filepath.Join(DataDirPath("tmp"), "oldaccount-"+account) tmpdir := filepath.Join(mox.DataDirPath("tmp"), "oldaccount-"+account)
if err := os.Rename(odir, tmpdir); err != nil { if err := os.Rename(odir, tmpdir); err != nil {
log.Errorx("moving old account data directory out of the way", err, slog.String("account", account)) log.Errorx("moving old account data directory out of the way", err, slog.String("account", account))
return fmt.Errorf("account removed, but account data directory %q could not be moved out of the way: %v", odir, err) return fmt.Errorf("account removed, but account data directory %q could not be moved out of the way: %v", odir, err)
@ -1093,12 +730,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
// //
// Must be called with config lock held. // Must be called with config lock held.
func checkAddressAvailable(addr smtp.Address) error { func checkAddressAvailable(addr smtp.Address) error {
dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()] dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()]
if !ok { if !ok {
return fmt.Errorf("domain does not exist") return fmt.Errorf("domain does not exist")
} }
lp := CanonicalLocalpart(addr.Localpart, dc) lp := mox.CanonicalLocalpart(addr.Localpart, dc)
if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok { if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok {
return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain)) return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) { } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator) return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
@ -1118,10 +755,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
a, ok := c.Accounts[account] a, ok := c.Accounts[account]
if !ok { if !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest) return fmt.Errorf("%w: account does not exist", ErrRequest)
@ -1135,9 +771,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
} }
dname := d.Name() dname := d.Name()
destAddr = "@" + dname destAddr = "@" + dname
if _, ok := Conf.Dynamic.Domains[dname]; !ok { if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest) return fmt.Errorf("%w: domain does not exist", ErrRequest)
} else if _, ok := Conf.accountDestinations[destAddr]; ok { } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok {
return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest) return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
} }
} else { } else {
@ -1167,7 +803,7 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
a.Destinations = nd a.Destinations = nd
nc.Accounts[account] = a nc.Accounts[account] = a
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("address added", slog.String("address", address), slog.String("account", account)) log.Info("address added", slog.String("address", address), slog.String("account", account))
@ -1187,17 +823,16 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
ad, ok := Conf.accountDestinations[address] ad, ok := mox.Conf.AccountDestinationsLocked[address]
if !ok { if !ok {
return fmt.Errorf("%w: address does not exists", ErrRequest) return fmt.Errorf("%w: address does not exists", ErrRequest)
} }
// Compose new config without modifying existing data structures. If we fail, we // Compose new config without modifying existing data structures. If we fail, we
// leave no trace. // leave no trace.
a, ok := Conf.Dynamic.Accounts[ad.Account] a, ok := mox.Conf.Dynamic.Accounts[ad.Account]
if !ok { if !ok {
return fmt.Errorf("internal error: cannot find account") return fmt.Errorf("internal error: cannot find account")
} }
@ -1241,12 +876,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if strings.HasPrefix(address, "@") { if strings.HasPrefix(address, "@") {
continue continue
} }
dc, ok := Conf.Dynamic.Domains[dom.Name()] dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
if !ok { if !ok {
return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true)) return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
} }
flp := CanonicalLocalpart(fa.Localpart, dc) flp := mox.CanonicalLocalpart(fa.Localpart, dc)
alp := CanonicalLocalpart(pa.Localpart, dc) alp := mox.CanonicalLocalpart(pa.Localpart, dc)
if alp != flp { if alp != flp {
// Keep for different localpart. // Keep for different localpart.
fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i]) fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
@ -1255,7 +890,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
na.FromIDLoginAddresses = fromIDLoginAddresses na.FromIDLoginAddresses = fromIDLoginAddresses
// And remove as member from aliases configured in domains. // And remove as member from aliases configured in domains.
domains := maps.Clone(Conf.Dynamic.Domains) domains := maps.Clone(mox.Conf.Dynamic.Domains)
for _, aa := range na.Aliases { for _, aa := range na.Aliases {
if aa.SubscriptionAddress != address { if aa.SubscriptionAddress != address {
continue continue
@ -1263,7 +898,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name()) aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
dom, ok := Conf.Dynamic.Domains[aa.Alias.Domain.Name()] dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
if !ok { if !ok {
return fmt.Errorf("cannot find domain for alias %s", aliasAddr) return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
} }
@ -1283,15 +918,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
} }
na.Aliases = nil // Filled when parsing config. na.Aliases = nil // Filled when parsing config.
nc := Conf.Dynamic nc := mox.Conf.Dynamic
nc.Accounts = map[string]config.Account{} nc.Accounts = map[string]config.Account{}
for name, a := range Conf.Dynamic.Accounts { for name, a := range mox.Conf.Dynamic.Accounts {
nc.Accounts[name] = a nc.Accounts[name] = a
} }
nc.Accounts[ad.Account] = na nc.Accounts[ad.Account] = na
nc.Domains = domains nc.Domains = domains
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account)) log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
@ -1393,10 +1028,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
} }
}() }()
Conf.dynamicMutex.Lock() defer mox.Conf.DynamicLockUnlock()()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic c := mox.Conf.Dynamic
acc, ok := c.Accounts[account] acc, ok := c.Accounts[account]
if !ok { if !ok {
return fmt.Errorf("%w: account not present", ErrRequest) return fmt.Errorf("%w: account not present", ErrRequest)
@ -1413,243 +1047,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
} }
nc.Accounts[account] = acc nc.Accounts[account] = acc
if err := writeDynamic(ctx, log, nc); err != nil { if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err) return fmt.Errorf("writing domains.conf: %w", err)
} }
log.Info("account fields saved", slog.String("account", account)) log.Info("account fields saved", slog.String("account", account))
return nil return nil
} }
type TLSMode uint8
const (
TLSModeImmediate TLSMode = 0
TLSModeSTARTTLS TLSMode = 1
TLSModeNone TLSMode = 2
)
type ProtocolConfig struct {
Host dns.Domain
Port int
TLSMode TLSMode
}
type ClientConfig struct {
IMAP ProtocolConfig
Submission ProtocolConfig
}
// ClientConfigDomain returns a single IMAP and Submission client configuration for
// a domain.
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
var haveIMAP, haveSubmission bool
domConf, ok := Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
gather := func(l config.Listener) (done bool) {
host := Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if !haveIMAP && l.IMAPS.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
rconfig.IMAP.TLSMode = TLSModeImmediate
haveIMAP = true
}
if !haveIMAP && l.IMAP.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
rconfig.IMAP.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.IMAP.TLSMode = TLSModeNone
}
haveIMAP = true
}
if !haveSubmission && l.Submissions.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
rconfig.Submission.TLSMode = TLSModeImmediate
haveSubmission = true
}
if !haveSubmission && l.Submission.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
rconfig.Submission.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.Submission.TLSMode = TLSModeNone
}
haveSubmission = true
}
return haveIMAP && haveSubmission
}
// Look at the public listener first. Most likely the intended configuration.
if public, ok := Conf.Static.Listeners["public"]; ok {
if gather(public) {
return
}
}
// Go through the other listeners in consistent order.
names := maps.Keys(Conf.Static.Listeners)
sort.Strings(names)
for _, name := range names {
if gather(Conf.Static.Listeners[name]) {
return
}
}
return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
}
// ClientConfigs holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfigs struct {
Entries []ClientConfigsEntry
}
type ClientConfigsEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
// domain.
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := Conf.Domain(d)
if !ok {
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
c := ClientConfigs{}
c.Entries = []ClientConfigsEntry{}
var listeners []string
for name := range Conf.Static.Listeners {
listeners = append(listeners, name)
}
sort.Slice(listeners, func(i, j int) bool {
return listeners[i] < listeners[j]
})
note := func(tls bool, requiretls bool) string {
if !tls {
return "plain text, no STARTTLS configured"
}
if requiretls {
return "STARTTLS required"
}
return "STARTTLS optional"
}
for _, name := range listeners {
l := Conf.Static.Listeners[name]
host := Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if l.Submissions.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
}
if l.IMAPS.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
}
if l.IMAP.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}
// IPs returns ip addresses we may be listening/receiving mail on or
// connecting/sending from to the outside.
func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
log := pkglog.WithContext(ctx)
// Try to gather all IPs we are listening on by going through the config.
// If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
var ips []net.IP
var ipv4all, ipv6all bool
for _, l := range Conf.Static.Listeners {
// If NATed, we don't know our external IPs.
if l.IPsNATed {
return nil, nil
}
check := l.IPs
if len(l.NATIPs) > 0 {
check = l.NATIPs
}
for _, s := range check {
ip := net.ParseIP(s)
if ip.IsUnspecified() {
if ip.To4() != nil {
ipv4all = true
} else {
ipv6all = true
}
continue
}
ips = append(ips, ip)
}
}
// We'll list the IPs on the interfaces. How useful is this? There is a good chance
// we're listening on all addresses because of a load balancer/firewall.
if ipv4all || ipv6all {
ifaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("listing network interfaces: %v", err)
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("listing addresses for network interface: %v", err)
}
if len(addrs) == 0 {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
log.Errorx("bad interface addr", err, slog.Any("address", addr))
continue
}
v4 := ip.To4() != nil
if ipv4all && v4 || ipv6all && !v4 {
ips = append(ips, ip)
}
}
}
}
if receiveOnly {
return ips, nil
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips, nil
}

168
admin/clientconfig.go Normal file
View file

@ -0,0 +1,168 @@
package admin
import (
"fmt"
"sort"
"golang.org/x/exp/maps"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
type TLSMode uint8
const (
TLSModeImmediate TLSMode = 0
TLSModeSTARTTLS TLSMode = 1
TLSModeNone TLSMode = 2
)
type ProtocolConfig struct {
Host dns.Domain
Port int
TLSMode TLSMode
}
type ClientConfig struct {
IMAP ProtocolConfig
Submission ProtocolConfig
}
// ClientConfigDomain returns a single IMAP and Submission client configuration for
// a domain.
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
var haveIMAP, haveSubmission bool
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
gather := func(l config.Listener) (done bool) {
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if !haveIMAP && l.IMAPS.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
rconfig.IMAP.TLSMode = TLSModeImmediate
haveIMAP = true
}
if !haveIMAP && l.IMAP.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
rconfig.IMAP.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.IMAP.TLSMode = TLSModeNone
}
haveIMAP = true
}
if !haveSubmission && l.Submissions.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
rconfig.Submission.TLSMode = TLSModeImmediate
haveSubmission = true
}
if !haveSubmission && l.Submission.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
rconfig.Submission.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.Submission.TLSMode = TLSModeNone
}
haveSubmission = true
}
return haveIMAP && haveSubmission
}
// Look at the public listener first. Most likely the intended configuration.
if public, ok := mox.Conf.Static.Listeners["public"]; ok {
if gather(public) {
return
}
}
// Go through the other listeners in consistent order.
names := maps.Keys(mox.Conf.Static.Listeners)
sort.Strings(names)
for _, name := range names {
if gather(mox.Conf.Static.Listeners[name]) {
return
}
}
return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
}
// ClientConfigs holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfigs struct {
Entries []ClientConfigsEntry
}
type ClientConfigsEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
// domain.
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
c := ClientConfigs{}
c.Entries = []ClientConfigsEntry{}
var listeners []string
for name := range mox.Conf.Static.Listeners {
listeners = append(listeners, name)
}
sort.Slice(listeners, func(i, j int) bool {
return listeners[i] < listeners[j]
})
note := func(tls bool, requiretls bool) string {
if !tls {
return "plain text, no STARTTLS configured"
}
if requiretls {
return "STARTTLS required"
}
return "STARTTLS optional"
}
for _, name := range listeners {
l := mox.Conf.Static.Listeners[name]
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if l.Submissions.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
}
if l.IMAPS.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
}
if l.IMAP.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}

320
admin/dnsrecords.go Normal file
View file

@ -0,0 +1,320 @@
package admin
import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"fmt"
"net/url"
"sort"
"strings"
"github.com/mjl-/adns"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/tlsrpt"
)
// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
// DomainRecords returns text lines describing DNS records required for configuring
// a domain.
//
// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
// that caID will be suggested. If acmeAccountURI is also set, CAA records also
// restricting issuance to that account ID will be suggested.
func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
d := domain.ASCII
h := mox.Conf.Static.HostnameDomain.ASCII
// The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
// ../testdata/integration/moxmail2.sh for selecting DNS records
records := []string{
"; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
"; Once your setup is working, you may want to increase the TTL.",
"$TTL 300",
"",
}
if public, ok := mox.Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
records = append(records,
`; DANE: These records indicate that a remote mail server trying to deliver email`,
`; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
`; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
`; hexadecimal hash. DANE-EE verification means only the certificate or public`,
`; key is verified, not whether the certificate is signed by a (centralized)`,
`; certificate authority (CA), is expired, or matches the host name.`,
`;`,
`; NOTE: Create the records below only once: They are for the machine, and apply`,
`; to all hosted domains.`,
)
if !hasDNSSEC {
records = append(records,
";",
"; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
"; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
"; commented out.",
)
}
addTLSA := func(privKey crypto.Signer) error {
spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
if err != nil {
return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
}
sum := sha256.Sum256(spkiBuf)
tlsaRecord := adns.TLSA{
Usage: adns.TLSAUsageDANEEE,
Selector: adns.TLSASelectorSPKI,
MatchType: adns.TLSAMatchTypeSHA256,
CertAssoc: sum[:],
}
var s string
if hasDNSSEC {
s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
} else {
s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
}
records = append(records, s)
return nil
}
for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
records = append(records, "")
}
if d != h {
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
"; messages (DSNs) sent from host:",
fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
)
}
if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
"",
)
}
records = append(records,
"; Deliver email for the domain to this host.",
fmt.Sprintf("%s. MX 10 %s.", d, h),
"",
"; Outgoing messages will be signed with the first two DKIM keys. The other two",
"; configured for backup, switching to them is just a config change.",
)
var selectors []string
for name := range domConf.DKIM.Selectors {
selectors = append(selectors, name)
}
sort.Slice(selectors, func(i, j int) bool {
return selectors[i] < selectors[j]
})
for _, name := range selectors {
sel := domConf.DKIM.Selectors[name]
dkimr := dkim.Record{
Version: "DKIM1",
Hashes: []string{"sha256"},
PublicKey: sel.Key.Public(),
}
if _, ok := sel.Key.(ed25519.PrivateKey); ok {
dkimr.Key = "ed25519"
} else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
}
txt, err := dkimr.Record()
if err != nil {
return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
}
if len(txt) > 100 {
records = append(records,
"; NOTE: The following is a single long record split over several lines for use",
"; in zone files. When adding through a DNS operator web interface, combine the",
"; strings into a single string, without ().",
)
}
s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt))
records = append(records, s)
}
dmarcr := dmarc.DefaultRecord
dmarcr.Policy = "reject"
if domConf.DMARC != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
}
dmarcr.AggregateReportAddresses = []dmarc.URI{
{Address: uri.String(), MaxSize: 10, Unit: "m"},
}
}
dspfr := spf.Record{Version: "spf1"}
for _, ip := range mox.DomainSPFIPs() {
mech := "ip4"
if ip.To4() == nil {
mech = "ip6"
}
dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
}
dspfr.Directives = append(dspfr.Directives,
spf.Directive{Mechanism: "mx"},
spf.Directive{Qualifier: "~", Mechanism: "all"},
)
dspftxt, err := dspfr.Record()
if err != nil {
return nil, fmt.Errorf("making domain spf record: %v", err)
}
records = append(records,
"",
"; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
"; ~all means softfail for anything else, which is done instead of -all to prevent older",
"; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
"",
"; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
"; should be rejected, and request reports. If you email through mailing lists that",
"; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
"; set the policy to p=none.",
fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; Remote servers can use MTA-STS to verify our TLS certificate with the",
"; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
"; STARTTLSTLS.",
fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
} else {
records = append(records,
"; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
"; domain or because mox.conf does not have a listener with MTA-STS configured.",
"",
)
}
if domConf.TLSRPT != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
"",
)
}
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
"; Client settings will reference a subdomain of the hosted domain, making it",
"; easier to migrate to a different server in the future by not requiring settings",
"; in all clients to be updated.",
fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
"",
)
}
records = append(records,
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and non-TLS ports are not to be used.",
"; These are optional and safe to leave out (e.g. if you have to click a lot in a",
"; DNS admin web interface).",
fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
)
if certIssuerDomainName != "" {
// ../rfc/8659:18 for CAA records.
records = append(records,
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
)
if acmeAccountURI != "" {
// ../rfc/8657:99 for accounturi.
// ../rfc/8657:147 for validationmethods.
records = append(records,
";",
"; Optionally limit certificates for this domain to the account ID and methods used by mox.",
fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
";",
"; Or alternatively only limit for email-specific subdomains, so you can use",
"; other accounts/methods for other subdomains.",
fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
)
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
)
}
if strings.HasSuffix(h, "."+d) {
records = append(records,
";",
"; And the mail hostname.",
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
)
}
} else {
// The string "will be suggested" is used by
// ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
// as end of DNS records.
records = append(records,
";",
"; Note: After starting up, once an ACME account has been created, CAA records",
"; that restrict issuance to the account will be suggested.",
)
}
}
return records, nil
}

23
ctl.go
View file

@ -21,6 +21,7 @@ import (
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message" "github.com/mjl-/mox/message"
@ -973,7 +974,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
localpart := ctl.xread() localpart := ctl.xread()
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain") ctl.xcheck(err, "parsing domain")
err = mox.DomainAdd(ctx, d, account, smtp.Localpart(localpart)) err = admin.DomainAdd(ctx, d, account, smtp.Localpart(localpart))
ctl.xcheck(err, "adding domain") ctl.xcheck(err, "adding domain")
ctl.xwriteok() ctl.xwriteok()
@ -986,7 +987,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
domain := ctl.xread() domain := ctl.xread()
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain") ctl.xcheck(err, "parsing domain")
err = mox.DomainRemove(ctx, d) err = admin.DomainRemove(ctx, d)
ctl.xcheck(err, "removing domain") ctl.xcheck(err, "removing domain")
ctl.xwriteok() ctl.xwriteok()
@ -999,7 +1000,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/ */
account := ctl.xread() account := ctl.xread()
address := ctl.xread() address := ctl.xread()
err := mox.AccountAdd(ctx, account, address) err := admin.AccountAdd(ctx, account, address)
ctl.xcheck(err, "adding account") ctl.xcheck(err, "adding account")
ctl.xwriteok() ctl.xwriteok()
@ -1010,7 +1011,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error < "ok" or error
*/ */
account := ctl.xread() account := ctl.xread()
err := mox.AccountRemove(ctx, account) err := admin.AccountRemove(ctx, account)
ctl.xcheck(err, "removing account") ctl.xcheck(err, "removing account")
ctl.xwriteok() ctl.xwriteok()
@ -1023,7 +1024,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/ */
address := ctl.xread() address := ctl.xread()
account := ctl.xread() account := ctl.xread()
err := mox.AddressAdd(ctx, address, account) err := admin.AddressAdd(ctx, address, account)
ctl.xcheck(err, "adding address") ctl.xcheck(err, "adding address")
ctl.xwriteok() ctl.xwriteok()
@ -1034,7 +1035,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error < "ok" or error
*/ */
address := ctl.xread() address := ctl.xread()
err := mox.AddressRemove(ctx, address) err := admin.AddressRemove(ctx, address)
ctl.xcheck(err, "removing address") ctl.xcheck(err, "removing address")
ctl.xwriteok() ctl.xwriteok()
@ -1099,7 +1100,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address") ctl.xcheck(err, "parsing address")
var alias config.Alias var alias config.Alias
xparseJSON(ctl, line, &alias) xparseJSON(ctl, line, &alias)
err = mox.AliasAdd(ctx, addr, alias) err = admin.AliasAdd(ctx, addr, alias)
ctl.xcheck(err, "adding alias") ctl.xcheck(err, "adding alias")
ctl.xwriteok() ctl.xwriteok()
@ -1118,7 +1119,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
allowmsgfrom := ctl.xread() allowmsgfrom := ctl.xread()
addr, err := smtp.ParseAddress(address) addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address") ctl.xcheck(err, "parsing address")
err = mox.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { err = admin.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
a, ok := d.Aliases[addr.Localpart.String()] a, ok := d.Aliases[addr.Localpart.String()]
if !ok { if !ok {
return fmt.Errorf("alias does not exist") return fmt.Errorf("alias does not exist")
@ -1159,7 +1160,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
address := ctl.xread() address := ctl.xread()
addr, err := smtp.ParseAddress(address) addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address") ctl.xcheck(err, "parsing address")
err = mox.AliasRemove(ctx, addr) err = admin.AliasRemove(ctx, addr)
ctl.xcheck(err, "removing alias") ctl.xcheck(err, "removing alias")
ctl.xwriteok() ctl.xwriteok()
@ -1176,7 +1177,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address") ctl.xcheck(err, "parsing address")
var addresses []string var addresses []string
xparseJSON(ctl, line, &addresses) xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesAdd(ctx, addr, addresses) err = admin.AliasAddressesAdd(ctx, addr, addresses)
ctl.xcheck(err, "adding addresses to alias") ctl.xcheck(err, "adding addresses to alias")
ctl.xwriteok() ctl.xwriteok()
@ -1193,7 +1194,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address") ctl.xcheck(err, "parsing address")
var addresses []string var addresses []string
xparseJSON(ctl, line, &addresses) xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesRemove(ctx, addr, addresses) err = admin.AliasAddressesRemove(ctx, addr, addresses)
ctl.xcheck(err, "removing addresses to alias") ctl.xcheck(err, "removing addresses to alias")
ctl.xwriteok() ctl.xwriteok()

View file

@ -11,7 +11,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"rsc.io/qr" "rsc.io/qr"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
) )
@ -70,13 +70,13 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
return return
} }
socketType := func(tlsMode mox.TLSMode) (string, error) { socketType := func(tlsMode admin.TLSMode) (string, error) {
switch tlsMode { switch tlsMode {
case mox.TLSModeImmediate: case admin.TLSModeImmediate:
return "SSL", nil return "SSL", nil
case mox.TLSModeSTARTTLS: case admin.TLSModeSTARTTLS:
return "STARTTLS", nil return "STARTTLS", nil
case mox.TLSModeNone: case admin.TLSModeNone:
return "plain", nil return "plain", nil
default: default:
return "", fmt.Errorf("unknown tls mode %v", tlsMode) return "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -84,7 +84,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
} }
var imapTLS, submissionTLS string var imapTLS, submissionTLS string
config, err := mox.ClientConfigDomain(addr.Domain) config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil { if err == nil {
imapTLS, err = socketType(config.IMAP.TLSMode) imapTLS, err = socketType(config.IMAP.TLSMode)
} }
@ -170,13 +170,13 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
} }
// tlsmode returns the "ssl" and "encryption" fields. // tlsmode returns the "ssl" and "encryption" fields.
tlsmode := func(tlsMode mox.TLSMode) (string, string, error) { tlsmode := func(tlsMode admin.TLSMode) (string, string, error) {
switch tlsMode { switch tlsMode {
case mox.TLSModeImmediate: case admin.TLSModeImmediate:
return "on", "TLS", nil return "on", "TLS", nil
case mox.TLSModeSTARTTLS: case admin.TLSModeSTARTTLS:
return "on", "", nil return "on", "", nil
case mox.TLSModeNone: case admin.TLSModeNone:
return "off", "", nil return "off", "", nil
default: default:
return "", "", fmt.Errorf("unknown tls mode %v", tlsMode) return "", "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -185,7 +185,7 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
var imapSSL, imapEncryption string var imapSSL, imapEncryption string
var submissionSSL, submissionEncryption string var submissionSSL, submissionEncryption string
config, err := mox.ClientConfigDomain(addr.Domain) config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil { if err == nil {
imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode) imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode)
} }

View file

@ -12,7 +12,7 @@ import (
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
) )
@ -122,7 +122,7 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
return nil, fmt.Errorf("parsing address: %v", err) return nil, fmt.Errorf("parsing address: %v", err)
} }
config, err := mox.ClientConfigDomain(addr.Domain) config, err := admin.ClientConfigDomain(addr.Domain)
if err != nil { if err != nil {
return nil, fmt.Errorf("getting config for domain: %v", err) return nil, fmt.Errorf("getting config for domain: %v", err)
} }
@ -175,12 +175,12 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
"IncomingMailServerUsername": addresses[0], "IncomingMailServerUsername": addresses[0],
"IncomingMailServerHostName": config.IMAP.Host.ASCII, "IncomingMailServerHostName": config.IMAP.Host.ASCII,
"IncomingMailServerPortNumber": config.IMAP.Port, "IncomingMailServerPortNumber": config.IMAP.Port,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate, "IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate,
"OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing... "OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
"OutgoingMailServerHostName": config.Submission.Host.ASCII, "OutgoingMailServerHostName": config.Submission.Host.ASCII,
"OutgoingMailServerPortNumber": config.Submission.Port, "OutgoingMailServerPortNumber": config.Submission.Port,
"OutgoingMailServerUsername": addresses[0], "OutgoingMailServerUsername": addresses[0],
"OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate, "OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate,
"OutgoingPasswordSameAsIncomingPassword": true, "OutgoingPasswordSameAsIncomingPassword": true,
"PayloadIdentifier": reverseAddr + ".email.account", "PayloadIdentifier": reverseAddr + ".email.account",
"PayloadType": "com.apple.mail.managed", "PayloadType": "com.apple.mail.managed",

View file

@ -25,6 +25,7 @@ import (
"github.com/mjl-/sconf" "github.com/mjl-/sconf"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
@ -421,7 +422,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
}, },
} }
dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"}) dkimKeyBuf, err := admin.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
xcheck(err, "making dkim key") xcheck(err, "making dkim key")
dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem" dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660) err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)

11
main.go
View file

@ -45,6 +45,7 @@ import (
"github.com/mjl-/sconf" "github.com/mjl-/sconf"
"github.com/mjl-/sherpa" "github.com/mjl-/sherpa"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dane" "github.com/mjl-/mox/dane"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
@ -570,7 +571,7 @@ configured over otherwise secured connections, like a VPN.
} }
func printClientConfig(d dns.Domain) { func printClientConfig(d dns.Domain) {
cc, err := mox.ClientConfigsDomain(d) cc, err := admin.ClientConfigsDomain(d)
xcheckf(err, "getting client config") xcheckf(err, "getting client config")
fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note") fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note")
for _, e := range cc.Entries { for _, e := range cc.Entries {
@ -1006,7 +1007,7 @@ configured.
} }
} }
records, err := mox.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI) records, err := admin.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
xcheckf(err, "records") xcheckf(err, "records")
fmt.Print(strings.Join(records, "\n") + "\n") fmt.Print(strings.Join(records, "\n") + "\n")
} }
@ -1539,7 +1540,7 @@ with DKIM, by mox.
c.Usage() c.Usage()
} }
buf, err := mox.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{}) buf, err := admin.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
xcheckf(err, "making rsa private key") xcheckf(err, "making rsa private key")
_, err = os.Stdout.Write(buf) _, err = os.Stdout.Write(buf)
xcheckf(err, "writing rsa private key") xcheckf(err, "writing rsa private key")
@ -2077,7 +2078,7 @@ so it is recommended to sign messages with both RSA and ed25519 keys.
c.Usage() c.Usage()
} }
buf, err := mox.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{}) buf, err := admin.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
xcheckf(err, "making dkim ed25519 key") xcheckf(err, "making dkim ed25519 key")
_, err = os.Stdout.Write(buf) _, err = os.Stdout.Write(buf)
xcheckf(err, "writing dkim ed25519 key") xcheckf(err, "writing dkim ed25519 key")
@ -2786,7 +2787,7 @@ printed.
} }
mustLoadConfig() mustLoadConfig()
current, lastknown, _, err := mox.LastKnown() current, lastknown, _, err := store.LastKnown()
if err != nil { if err != nil {
log.Printf("getting last known version: %s", err) log.Printf("getting last known version: %s", err)
} else { } else {

View file

@ -86,11 +86,13 @@ type Config struct {
Dynamic config.Dynamic // Can only be accessed directly by tests. Use methods on Config for locked access. Dynamic config.Dynamic // Can only be accessed directly by tests. Use methods on Config for locked access.
dynamicMtime time.Time dynamicMtime time.Time
DynamicLastCheck time.Time // For use by quickstart only to skip checks. DynamicLastCheck time.Time // For use by quickstart only to skip checks.
// From canonical full address (localpart@domain, lower-cased when // From canonical full address (localpart@domain, lower-cased when
// case-insensitive, stripped of catchall separator) to account and address. // case-insensitive, stripped of catchall separator) to account and address.
// Domains are IDNA names in utf8. // Domains are IDNA names in utf8. Dynamic config lock must be held when accessing.
accountDestinations map[string]AccountDestination AccountDestinationsLocked map[string]AccountDestination
// Like accountDestinations, but for aliases.
// Like AccountDestinationsLocked, but for aliases.
aliases map[string]config.Alias aliases map[string]config.Alias
} }
@ -142,9 +144,11 @@ func (c *Config) LogLevels() map[string]slog.Level {
return c.copyLogLevels() return c.copyLogLevels()
} }
func (c *Config) withDynamicLock(fn func()) { // DynamicLockUnlock locks the dynamic config, will try updating the latest state
// from disk, and return an unlock function. Should be called as "defer
// Conf.DynamicLockUnlock()()".
func (c *Config) DynamicLockUnlock() func() {
c.dynamicMutex.Lock() c.dynamicMutex.Lock()
defer c.dynamicMutex.Unlock()
now := time.Now() now := time.Now()
if now.Sub(c.DynamicLastCheck) > time.Second { if now.Sub(c.DynamicLastCheck) > time.Second {
c.DynamicLastCheck = now c.DynamicLastCheck = now
@ -159,6 +163,11 @@ func (c *Config) withDynamicLock(fn func()) {
} }
} }
} }
return c.dynamicMutex.Unlock
}
func (c *Config) withDynamicLock(fn func()) {
defer c.DynamicLockUnlock()()
fn() fn()
} }
@ -170,7 +179,7 @@ func (c *Config) loadDynamic() []error {
} }
c.Dynamic = d c.Dynamic = d
c.dynamicMtime = mtime c.dynamicMtime = mtime
c.accountDestinations = accDests c.AccountDestinationsLocked = accDests
c.aliases = aliases c.aliases = aliases
c.allowACMEHosts(pkglog, true) c.allowACMEHosts(pkglog, true)
return nil return nil
@ -213,7 +222,7 @@ func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]c
m := map[string]string{} m := map[string]string{}
aliases := map[string]config.Alias{} aliases := map[string]config.Alias{}
c.withDynamicLock(func() { c.withDynamicLock(func() {
for addr, ad := range c.accountDestinations { for addr, ad := range c.AccountDestinationsLocked {
if strings.HasSuffix(addr, suffix) { if strings.HasSuffix(addr, suffix) {
if ad.Catchall { if ad.Catchall {
m[""] = ad.Account m[""] = ad.Account
@ -247,7 +256,7 @@ func (c *Config) Account(name string) (acc config.Account, ok bool) {
func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) { func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) {
c.withDynamicLock(func() { c.withDynamicLock(func() {
accDest, ok = c.accountDestinations[addr] accDest, ok = c.AccountDestinationsLocked[addr]
if !ok { if !ok {
var a config.Alias var a config.Alias
a, ok = c.aliases[addr] a, ok = c.aliases[addr]
@ -345,9 +354,13 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
// todo future: write config parsing & writing code that can read a config and remembers the exact tokens including newlines and comments, and can write back a modified file. the goal is to be able to write a config file automatically (after changing fields through the ui), but not loose comments and whitespace, to still get useful diffs for storing the config in a version control system. // todo future: write config parsing & writing code that can read a config and remembers the exact tokens including newlines and comments, and can write back a modified file. the goal is to be able to write a config file automatically (after changing fields through the ui), but not loose comments and whitespace, to still get useful diffs for storing the config in a version control system.
// must be called with lock held. // WriteDynamicLocked prepares an updated internal state for the new dynamic
// config, then writes it to disk and activates it.
//
// Returns ErrConfig if the configuration is not valid. // Returns ErrConfig if the configuration is not valid.
func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error { //
// Must be called with config lock held.
func WriteDynamicLocked(ctx context.Context, log mlog.Log, c config.Dynamic) error {
accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c) accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c)
if len(errs) > 0 { if len(errs) > 0 {
errstrs := make([]string, len(errs)) errstrs := make([]string, len(errs))
@ -399,7 +412,7 @@ func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
Conf.dynamicMtime = fi.ModTime() Conf.dynamicMtime = fi.ModTime()
Conf.DynamicLastCheck = time.Now() Conf.DynamicLastCheck = time.Now()
Conf.Dynamic = c Conf.Dynamic = c
Conf.accountDestinations = accDests Conf.AccountDestinationsLocked = accDests
Conf.aliases = aliases Conf.aliases = aliases
Conf.allowACMEHosts(log, true) Conf.allowACMEHosts(log, true)
@ -440,7 +453,7 @@ func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEH
// SetConfig sets a new config. Not to be used during normal operation. // SetConfig sets a new config. Not to be used during normal operation.
func SetConfig(c *Config) { func SetConfig(c *Config) {
// Cannot just assign *c to Conf, it would copy the mutex. // Cannot just assign *c to Conf, it would copy the mutex.
Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations, c.aliases} Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.AccountDestinationsLocked, c.aliases}
// If we have non-standard CA roots, use them for all HTTPS requests. // If we have non-standard CA roots, use them for all HTTPS requests.
if Conf.Static.TLS.CertPool != nil { if Conf.Static.TLS.CertPool != nil {
@ -491,7 +504,7 @@ func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadT
} }
pp := filepath.Join(filepath.Dir(p), "domains.conf") pp := filepath.Join(filepath.Dir(p), "domains.conf")
c.Dynamic, c.dynamicMtime, c.accountDestinations, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static) c.Dynamic, c.dynamicMtime, c.AccountDestinationsLocked, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
if !checkOnly { if !checkOnly {
c.allowACMEHosts(log, checkACMEHosts) c.allowACMEHosts(log, checkACMEHosts)

View file

@ -5,11 +5,18 @@ import (
) )
// ConfigDirPath returns the path to "f". Either f itself when absolute, or // ConfigDirPath returns the path to "f". Either f itself when absolute, or
// interpreted relative to the directory of the current config file. // interpreted relative to the directory of the static configuration file
// (mox.conf).
func ConfigDirPath(f string) string { func ConfigDirPath(f string) string {
return configDirPath(ConfigStaticPath, f) return configDirPath(ConfigStaticPath, f)
} }
// Like ConfigDirPath, but relative paths are interpreted relative to the directory
// of the dynamic configuration file (domains.conf).
func ConfigDynamicDirPath(f string) string {
return configDirPath(ConfigDynamicPath, f)
}
// DataDirPath returns to the path to "f". Either f itself when absolute, or // DataDirPath returns to the path to "f". Either f itself when absolute, or
// interpreted relative to the data directory from the currently active // interpreted relative to the data directory from the currently active
// configuration. // configuration.

View file

@ -1,6 +1,9 @@
package mox package mox
import ( import (
"context"
"fmt"
"log/slog"
"net" "net"
) )
@ -19,3 +22,109 @@ func Network(ip string) string {
} }
return "tcp6" return "tcp6"
} }
// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
// transports.
func DomainSPFIPs() (ips []net.IP) {
for _, l := range Conf.Static.Listeners {
if !l.SMTP.Enabled || l.IPsNATed {
continue
}
ipstrs := l.IPs
if len(l.NATIPs) > 0 {
ipstrs = l.NATIPs
}
for _, ipstr := range ipstrs {
ip := net.ParseIP(ipstr)
if ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips
}
// IPs returns ip addresses we may be listening/receiving mail on or
// connecting/sending from to the outside.
func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
log := pkglog.WithContext(ctx)
// Try to gather all IPs we are listening on by going through the config.
// If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
var ips []net.IP
var ipv4all, ipv6all bool
for _, l := range Conf.Static.Listeners {
// If NATed, we don't know our external IPs.
if l.IPsNATed {
return nil, nil
}
check := l.IPs
if len(l.NATIPs) > 0 {
check = l.NATIPs
}
for _, s := range check {
ip := net.ParseIP(s)
if ip.IsUnspecified() {
if ip.To4() != nil {
ipv4all = true
} else {
ipv6all = true
}
continue
}
ips = append(ips, ip)
}
}
// We'll list the IPs on the interfaces. How useful is this? There is a good chance
// we're listening on all addresses because of a load balancer/firewall.
if ipv4all || ipv6all {
ifaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("listing network interfaces: %v", err)
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("listing addresses for network interface: %v", err)
}
if len(addrs) == 0 {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
log.Errorx("bad interface addr", err, slog.Any("address", addr))
continue
}
v4 := ip.To4() != nil
if ipv4all && v4 || ipv6all && !v4 {
ips = append(ips, ip)
}
}
}
}
if receiveOnly {
return ips, nil
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips, nil
}

24
mox-/txt.go Normal file
View file

@ -0,0 +1,24 @@
package mox
// 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 {
if len(s) <= 100 {
return `"` + s + `"`
}
r := "(\n"
for len(s) > 0 {
n := len(s)
if n > 100 {
n = 100
}
if r != "" {
r += " "
}
r += "\t\t\"" + s[:n] + "\"\n"
s = s[n:]
}
r += "\t)"
return r
}

View file

@ -28,6 +28,7 @@ import (
"github.com/mjl-/sconf" "github.com/mjl-/sconf"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/dnsbl" "github.com/mjl-/mox/dnsbl"
@ -827,9 +828,9 @@ and check the admin page for the needed DNS records.`)
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below. mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
accountConf := mox.MakeAccountConfig(addr) accountConf := admin.MakeAccountConfig(addr)
const withMTASTS = true const withMTASTS = true
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS) confDomain, keyPaths, err := admin.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS)
if err != nil { if err != nil {
fatalf("making domain config: %s", err) fatalf("making domain config: %s", err)
} }
@ -989,7 +990,7 @@ have been configured correctly. The DNS records to add:
// priming dns caches with negative/absent records, causing our "quick setup" to // priming dns caches with negative/absent records, causing our "quick setup" to
// appear to fail or take longer than "quick". // appear to fail or take longer than "quick".
records, err := mox.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "") records, err := admin.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "")
if err != nil { if err != nil {
fatalf("making required DNS records") fatalf("making required DNS records")
} }

View file

@ -266,7 +266,7 @@ Only implemented on unix systems, not Windows.
if mox.Conf.Static.CheckUpdates { if mox.Conf.Static.CheckUpdates {
checkUpdates := func() time.Duration { checkUpdates := func() time.Duration {
next := 24 * time.Hour next := 24 * time.Hour
current, lastknown, mtime, err := mox.LastKnown() current, lastknown, mtime, err := store.LastKnown()
if err != nil { if err != nil {
log.Infox("determining own version before checking for updates, trying again in 24h", err) log.Infox("determining own version before checking for updates, trying again in 24h", err)
return next return next
@ -350,7 +350,7 @@ Only implemented on unix systems, not Windows.
slog.Any("current", current), slog.Any("current", current),
slog.Any("lastknown", lastknown), slog.Any("lastknown", lastknown),
slog.Any("latest", latest)) slog.Any("latest", latest))
if err := mox.StoreLastKnown(latest); err != nil { if err := store.StoreLastKnown(latest); err != nil {
// This will be awkward, we'll keep notifying the postmaster once every 24h... // This will be awkward, we'll keep notifying the postmaster once every 24h...
log.Infox("updating last known version", err) log.Infox("updating last known version", err)
} }

View file

@ -1,4 +1,4 @@
package mox package store
import ( import (
"fmt" "fmt"
@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/updates" "github.com/mjl-/mox/updates"
) )
@ -13,7 +14,7 @@ import (
// StoreLastKnown stores the the last known version. Future update checks compare // StoreLastKnown stores the the last known version. Future update checks compare
// against it, or the currently running version, whichever is newer. // against it, or the currently running version, whichever is newer.
func StoreLastKnown(v updates.Version) error { func StoreLastKnown(v updates.Version) error {
return os.WriteFile(DataDirPath("lastknownversion"), []byte(v.String()), 0660) return os.WriteFile(mox.DataDirPath("lastknownversion"), []byte(v.String()), 0660)
} }
// LastKnown returns the last known version that has been mentioned in an update // LastKnown returns the last known version that has been mentioned in an update
@ -21,7 +22,7 @@ func StoreLastKnown(v updates.Version) error {
func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) { func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) {
curv, curerr := updates.ParseVersion(moxvar.VersionBare) curv, curerr := updates.ParseVersion(moxvar.VersionBare)
p := DataDirPath("lastknownversion") p := mox.DataDirPath("lastknownversion")
fi, _ := os.Stat(p) fi, _ := os.Stat(p)
if fi != nil { if fi != nil {
mtime = fi.ModTime() mtime = fi.ModTime()

View file

@ -26,6 +26,7 @@ import (
"github.com/mjl-/sherpadoc" "github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom" "github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
@ -110,7 +111,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
return return
} }
// If caller tried saving a config that is invalid, or because of a bad request, cause a user error. // If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) { if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
xcheckuserf(ctx, err, format, args...) xcheckuserf(ctx, err, format, args...)
} }
@ -433,7 +434,7 @@ func (Account) Account(ctx context.Context) (account config.Account, storageUsed
// for the account. // for the account.
func (Account) AccountSaveFullName(ctx context.Context, fullName string) { func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FullName = fullName acc.FullName = fullName
}) })
xcheckf(ctx, err, "saving account full name") xcheckf(ctx, err, "saving account full name")
@ -445,7 +446,7 @@ func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) { func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
curDest, ok := conf.Destinations[destName] curDest, ok := conf.Destinations[destName]
if !ok { if !ok {
xcheckuserf(ctx, errors.New("not found"), "looking up destination") xcheckuserf(ctx, errors.New("not found"), "looking up destination")
@ -527,7 +528,7 @@ func (Account) SuppressionRemove(ctx context.Context, address string) {
// to be delivered, or all if empty/nil. // to be delivered, or all if empty/nil.
func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) { func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" { if url == "" {
acc.OutgoingWebhook = nil acc.OutgoingWebhook = nil
} else { } else {
@ -566,7 +567,7 @@ func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization st
// the Authorization header in requests. // the Authorization header in requests.
func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) { func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" { if url == "" {
acc.IncomingWebhook = nil acc.IncomingWebhook = nil
} else { } else {
@ -611,7 +612,7 @@ func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization st
// MAIL FROM addresses ("fromid") for deliveries from the queue. // MAIL FROM addresses ("fromid") for deliveries from the queue.
func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) { func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FromIDLoginAddresses = loginAddresses acc.FromIDLoginAddresses = loginAddresses
}) })
xcheckf(ctx, err, "saving account fromid login addresses") xcheckf(ctx, err, "saving account fromid login addresses")
@ -620,7 +621,7 @@ func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []st
// KeepRetiredPeriodsSave saves periods to save retired messages and webhooks. // KeepRetiredPeriodsSave saves periods to save retired messages and webhooks.
func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) { func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod
acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod
}) })
@ -631,7 +632,7 @@ func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePer
// junk/nonjunk when moved to mailboxes matching certain regular expressions. // junk/nonjunk when moved to mailboxes matching certain regular expressions.
func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) { func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.AutomaticJunkFlags = config.AutomaticJunkFlags{ acc.AutomaticJunkFlags = config.AutomaticJunkFlags{
Enabled: enabled, Enabled: enabled,
JunkMailboxRegexp: junkRegexp, JunkMailboxRegexp: junkRegexp,
@ -646,7 +647,7 @@ func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkReg
// is disabled. Otherwise all fields except Threegrams are stored. // is disabled. Otherwise all fields except Threegrams are stored.
func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) { func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if junkFilter == nil { if junkFilter == nil {
acc.JunkFilter = nil acc.JunkFilter = nil
return return
@ -664,7 +665,7 @@ func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter
// RejectsSave saves the RejectsMailbox and KeepRejects settings. // RejectsSave saves the RejectsMailbox and KeepRejects settings.
func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) { func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.RejectsMailbox = mailbox acc.RejectsMailbox = mailbox
acc.KeepRejects = keep acc.KeepRejects = keep
}) })

View file

@ -45,6 +45,7 @@ import (
"github.com/mjl-/sherpadoc" "github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom" "github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc" "github.com/mjl-/mox/dmarc"
@ -209,7 +210,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
return return
} }
// If caller tried saving a config that is invalid, or because of a bad request, cause a user error. // If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) { if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
xcheckuserf(ctx, err, format, args...) xcheckuserf(ctx, err, format, args...)
} }
@ -1898,7 +1899,7 @@ func (Admin) MonitorDNSBLsSave(ctx context.Context, text string) {
zones = append(zones, d) zones = append(zones, d)
} }
err := mox.ConfigSave(ctx, func(conf *config.Dynamic) { err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
conf.MonitorDNSBLs = make([]string, len(zones)) conf.MonitorDNSBLs = make([]string, len(zones))
conf.MonitorDNSBLZones = nil conf.MonitorDNSBLZones = nil
for i, z := range zones { for i, z := range zones {
@ -1944,7 +1945,7 @@ func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string {
} }
} }
records, err := mox.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI) records, err := admin.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
xcheckf(ctx, err, "dns records") xcheckf(ctx, err, "dns records")
return records return records
} }
@ -1954,7 +1955,7 @@ func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart strin
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain") xcheckuserf(ctx, err, "parsing domain")
err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart))) err = admin.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart)))
xcheckf(ctx, err, "adding domain") xcheckf(ctx, err, "adding domain")
} }
@ -1963,32 +1964,32 @@ func (Admin) DomainRemove(ctx context.Context, domain string) {
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain") xcheckuserf(ctx, err, "parsing domain")
err = mox.DomainRemove(ctx, d) err = admin.DomainRemove(ctx, d)
xcheckf(ctx, err, "removing domain") xcheckf(ctx, err, "removing domain")
} }
// AccountAdd adds existing a new account, with an initial email address, and // AccountAdd adds existing a new account, with an initial email address, and
// reloads the configuration. // reloads the configuration.
func (Admin) AccountAdd(ctx context.Context, accountName, address string) { func (Admin) AccountAdd(ctx context.Context, accountName, address string) {
err := mox.AccountAdd(ctx, accountName, address) err := admin.AccountAdd(ctx, accountName, address)
xcheckf(ctx, err, "adding account") xcheckf(ctx, err, "adding account")
} }
// AccountRemove removes an existing account and reloads the configuration. // AccountRemove removes an existing account and reloads the configuration.
func (Admin) AccountRemove(ctx context.Context, accountName string) { func (Admin) AccountRemove(ctx context.Context, accountName string) {
err := mox.AccountRemove(ctx, accountName) err := admin.AccountRemove(ctx, accountName)
xcheckf(ctx, err, "removing account") xcheckf(ctx, err, "removing account")
} }
// AddressAdd adds a new address to the account, which must already exist. // AddressAdd adds a new address to the account, which must already exist.
func (Admin) AddressAdd(ctx context.Context, address, accountName string) { func (Admin) AddressAdd(ctx context.Context, address, accountName string) {
err := mox.AddressAdd(ctx, address, accountName) err := admin.AddressAdd(ctx, address, accountName)
xcheckf(ctx, err, "adding address") xcheckf(ctx, err, "adding address")
} }
// AddressRemove removes an existing address. // AddressRemove removes an existing address.
func (Admin) AddressRemove(ctx context.Context, address string) { func (Admin) AddressRemove(ctx context.Context, address string) {
err := mox.AddressRemove(ctx, address) err := admin.AddressRemove(ctx, address)
xcheckf(ctx, err, "removing address") xcheckf(ctx, err, "removing address")
} }
@ -2012,7 +2013,7 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) {
// AccountSettingsSave set new settings for an account that only an admin can set. // AccountSettingsSave set new settings for an account that only an admin can set.
func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) { func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) {
err := mox.AccountSave(ctx, accountName, func(acc *config.Account) { err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay
acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay
acc.QuotaMessageSize = maxMsgSize acc.QuotaMessageSize = maxMsgSize
@ -2023,11 +2024,11 @@ func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOut
// ClientConfigsDomain returns configurations for email clients, IMAP and // ClientConfigsDomain returns configurations for email clients, IMAP and
// Submission (SMTP) for the domain. // Submission (SMTP) for the domain.
func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs { func (Admin) ClientConfigsDomain(ctx context.Context, domain string) admin.ClientConfigs {
d, err := dns.ParseDomain(domain) d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain") xcheckuserf(ctx, err, "parsing domain")
cc, err := mox.ClientConfigsDomain(d) cc, err := admin.ClientConfigsDomain(d)
xcheckf(ctx, err, "client config for domain") xcheckf(ctx, err, "client config for domain")
return cc return cc
} }
@ -2281,7 +2282,7 @@ func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf Webserver
domainRedirects[x[0]] = x[1] domainRedirects[x[0]] = x[1]
} }
err := mox.ConfigSave(ctx, func(conf *config.Dynamic) { err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
conf.WebDomainRedirects = domainRedirects conf.WebDomainRedirects = domainRedirects
conf.WebHandlers = newConf.WebHandlers conf.WebHandlers = newConf.WebHandlers
}) })
@ -2458,7 +2459,7 @@ func (Admin) Config(ctx context.Context) config.Dynamic {
// AccountRoutesSave saves routes for an account. // AccountRoutesSave saves routes for an account.
func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes []config.Route) { func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes []config.Route) {
err := mox.AccountSave(ctx, accountName, func(acc *config.Account) { err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
acc.Routes = routes acc.Routes = routes
}) })
xcheckf(ctx, err, "saving account routes") xcheckf(ctx, err, "saving account routes")
@ -2466,7 +2467,7 @@ func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes [
// DomainRoutesSave saves routes for a domain. // DomainRoutesSave saves routes for a domain.
func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) { func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Routes = routes domain.Routes = routes
return nil return nil
}) })
@ -2475,7 +2476,7 @@ func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []c
// RoutesSave saves global routes. // RoutesSave saves global routes.
func (Admin) RoutesSave(ctx context.Context, routes []config.Route) { func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
err := mox.ConfigSave(ctx, func(config *config.Dynamic) { err := admin.ConfigSave(ctx, func(config *config.Dynamic) {
config.Routes = routes config.Routes = routes
}) })
xcheckf(ctx, err, "saving global routes") xcheckf(ctx, err, "saving global routes")
@ -2483,7 +2484,7 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
// DomainDescriptionSave saves the description for a domain. // DomainDescriptionSave saves the description for a domain.
func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) { func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Description = descr domain.Description = descr
return nil return nil
}) })
@ -2492,7 +2493,7 @@ func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string
// DomainClientSettingsDomainSave saves the client settings domain for a domain. // DomainClientSettingsDomainSave saves the client settings domain for a domain.
func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) { func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.ClientSettingsDomain = clientSettingsDomain domain.ClientSettingsDomain = clientSettingsDomain
return nil return nil
}) })
@ -2502,7 +2503,7 @@ func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, cli
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive // DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain. // settings for a domain.
func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) { func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.LocalpartCatchallSeparator = localpartCatchallSeparator domain.LocalpartCatchallSeparator = localpartCatchallSeparator
domain.LocalpartCaseSensitive = localpartCaseSensitive domain.LocalpartCaseSensitive = localpartCaseSensitive
return nil return nil
@ -2514,7 +2515,7 @@ func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpar
// configuration for a domain. If localpart is empty, processing reports is // configuration for a domain. If localpart is empty, processing reports is
// disabled. // disabled.
func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) { func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" { if localpart == "" {
d.DMARC = nil d.DMARC = nil
} else { } else {
@ -2534,7 +2535,7 @@ func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart,
// configuration for a domain. If localpart is empty, processing reports is // configuration for a domain. If localpart is empty, processing reports is
// disabled. // disabled.
func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) { func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" { if localpart == "" {
d.TLSRPT = nil d.TLSRPT = nil
} else { } else {
@ -2553,7 +2554,7 @@ func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart,
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty, // DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served. // no MTASTS policy is served.
func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) { func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if policyID == "" { if policyID == "" {
d.MTASTS = nil d.MTASTS = nil
} else { } else {
@ -2576,7 +2577,7 @@ func (Admin) DomainDKIMAdd(ctx context.Context, domainName, selector, algorithm,
xcheckuserf(ctx, err, "parsing domain") xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector) s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector") xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime) err = admin.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime)
xcheckf(ctx, err, "adding dkim key") xcheckf(ctx, err, "adding dkim key")
} }
@ -2586,7 +2587,7 @@ func (Admin) DomainDKIMRemove(ctx context.Context, domainName, selector string)
xcheckuserf(ctx, err, "parsing domain") xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector) s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector") xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMRemove(ctx, d, s) err = admin.DKIMRemove(ctx, d, s)
xcheckf(ctx, err, "removing dkim key") xcheckf(ctx, err, "removing dkim key")
} }
@ -2600,7 +2601,7 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma
} }
} }
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error { err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if len(selectors) != len(d.DKIM.Selectors) { if len(selectors) != len(d.DKIM.Selectors) {
xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors") xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors")
} }
@ -2649,7 +2650,7 @@ func xparseAddress(ctx context.Context, lp, domain string) smtp.Address {
func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) { func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) {
addr := xparseAddress(ctx, aliaslp, domainName) addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAdd(ctx, addr, alias) err := admin.AliasAdd(ctx, addr, alias)
xcheckf(ctx, err, "adding alias") xcheckf(ctx, err, "adding alias")
} }
@ -2660,24 +2661,24 @@ func (Admin) AliasUpdate(ctx context.Context, aliaslp string, domainName string,
ListMembers: listMembers, ListMembers: listMembers,
AllowMsgFrom: allowMsgFrom, AllowMsgFrom: allowMsgFrom,
} }
err := mox.AliasUpdate(ctx, addr, alias) err := admin.AliasUpdate(ctx, addr, alias)
xcheckf(ctx, err, "saving alias") xcheckf(ctx, err, "saving alias")
} }
func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) { func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) {
addr := xparseAddress(ctx, aliaslp, domainName) addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasRemove(ctx, addr) err := admin.AliasRemove(ctx, addr)
xcheckf(ctx, err, "removing alias") xcheckf(ctx, err, "removing alias")
} }
func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) { func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName) addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesAdd(ctx, addr, addresses) err := admin.AliasAddressesAdd(ctx, addr, addresses)
xcheckf(ctx, err, "adding address to alias") xcheckf(ctx, err, "adding address to alias")
} }
func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) { func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName) addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesRemove(ctx, addr, addresses) err := admin.AliasAddressesRemove(ctx, addr, addresses)
xcheckf(ctx, err, "removing address from alias") xcheckf(ctx, err, "removing address from alias")
} }

View file

@ -33,6 +33,7 @@ import (
"github.com/mjl-/sherpadoc" "github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom" "github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
@ -1986,7 +1987,7 @@ func parseListID(s string) (listID string, dom dns.Domain) {
func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) { func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
dest, ok := acc.Destinations[rcptTo] dest, ok := acc.Destinations[rcptTo]
if !ok { if !ok {
// todo: we could find the catchall address and add the rule, or add the address explicitly. // todo: we could find the catchall address and add the rule, or add the address explicitly.
@ -2007,7 +2008,7 @@ func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Rul
func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) { func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) { err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
dest, ok := acc.Destinations[rcptTo] dest, ok := acc.Destinations[rcptTo]
if !ok { if !ok {
xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address") xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")