package admin import ( "bytes" "context" "crypto/ed25519" cryptorand "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" "fmt" "log/slog" "os" "path/filepath" "slices" "strings" "time" "golang.org/x/exp/maps" "github.com/mjl-/mox/config" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/junk" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/store" ) var pkglog = mlog.New("admin", nil) var ErrRequest = errors.New("bad request") // MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use // with DKIM. // selector and domain can be empty. If not, they are used in the note. func MakeDKIMEd25519Key(selector, domain dns.Domain) ([]byte, error) { _, privKey, err := ed25519.GenerateKey(cryptorand.Reader) if err != nil { return nil, fmt.Errorf("generating key: %w", err) } pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey) if err != nil { return nil, fmt.Errorf("marshal key: %w", err) } block := &pem.Block{ Type: "PRIVATE KEY", Headers: map[string]string{ "Note": dkimKeyNote("ed25519", selector, domain), }, Bytes: pkcs8, } b := &bytes.Buffer{} if err := pem.Encode(b, block); err != nil { return nil, fmt.Errorf("encoding pem: %w", err) } return b.Bytes(), nil } func dkimKeyNote(kind string, selector, domain dns.Domain) string { s := kind + " dkim private key" var zero dns.Domain if selector != zero && domain != zero { s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII) } s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339)) return s } // MakeDKIMRSAKey returns a PEM buffer containing an rsa key for use with // DKIM. // selector and domain can be empty. If not, they are used in the note. func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) { // 2048 bits seems reasonable in 2022, 1024 is on the low side, larger // keys may not fit in UDP DNS response. privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048) if err != nil { return nil, fmt.Errorf("generating key: %w", err) } pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey) if err != nil { return nil, fmt.Errorf("marshal key: %w", err) } block := &pem.Block{ Type: "PRIVATE KEY", Headers: map[string]string{ "Note": dkimKeyNote("rsa-2048", selector, domain), }, Bytes: pkcs8, } b := &bytes.Buffer{} if err := pem.Encode(b, block); err != nil { return nil, fmt.Errorf("encoding pem: %w", err) } return b.Bytes(), nil } // MakeAccountConfig returns a new account configuration for an email address. func MakeAccountConfig(addr smtp.Address) config.Account { account := config.Account{ Domain: addr.Domain.Name(), Destinations: map[string]config.Destination{ addr.String(): {}, }, RejectsMailbox: "Rejects", JunkFilter: &config.JunkFilter{ Threshold: 0.95, Params: junk.Params{ Onegrams: true, MaxPower: .01, TopWords: 10, IgnoreWords: .1, RareWords: 2, }, }, } account.AutomaticJunkFlags.Enabled = true account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)" account.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)" account.SubjectPass.Period = 12 * time.Hour return account } func writeFile(log mlog.Log, path string, data []byte) error { os.MkdirAll(filepath.Dir(path), 0770) f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660) if err != nil { return fmt.Errorf("creating file %s: %s", path, err) } defer func() { if f != nil { err := f.Close() log.Check(err, "closing file after error") err = os.Remove(path) log.Check(err, "removing file after error", slog.String("path", path)) } }() if _, err := f.Write(data); err != nil { return fmt.Errorf("writing file %s: %s", path, err) } if err := f.Close(); err != nil { return fmt.Errorf("close file: %v", err) } f = nil return nil } // MakeDomainConfig makes a new config for a domain, creating DKIM keys, using // accountName for DMARC and TLS reports. func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) { log := pkglog.WithContext(ctx) now := time.Now() year := now.Format("2006") timestamp := now.Format("20060102T150405") var paths []string defer func() { for _, p := range paths { err := os.Remove(p) log.Check(err, "removing path for domain config", slog.String("path", p)) } }() confDKIM := config.DKIM{ Selectors: map[string]config.Selector{}, } addSelector := func(kind, name string, privKey []byte) error { record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII) keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) p := mox.ConfigDynamicDirPath(keyPath) if err := writeFile(log, p, privKey); err != nil { return err } paths = append(paths, p) confDKIM.Selectors[name] = config.Selector{ // Example from RFC has 5 day between signing and expiration. ../rfc/6376:1393 // Expiration is not intended as antireplay defense, but it may help. ../rfc/6376:1340 // Messages in the wild have been observed with 2 hours and 1 year expiration. Expiration: "72h", PrivateKeyFile: keyPath, } return nil } addEd25519 := func(name string) error { key, err := MakeDKIMEd25519Key(dns.Domain{ASCII: name}, domain) if err != nil { return fmt.Errorf("making dkim ed25519 private key: %s", err) } return addSelector("ed25519", name, key) } addRSA := func(name string) error { key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain) if err != nil { return fmt.Errorf("making dkim rsa private key: %s", err) } return addSelector("rsa2048", name, key) } if err := addEd25519(year + "a"); err != nil { return config.Domain{}, nil, err } if err := addRSA(year + "b"); err != nil { return config.Domain{}, nil, err } if err := addEd25519(year + "c"); err != nil { return config.Domain{}, nil, err } if err := addRSA(year + "d"); err != nil { return config.Domain{}, nil, err } // We sign with the first two. In case they are misused, the switch to the other // keys is easy, just change the config. Operators should make the public key field // of the misused keys empty in the DNS records to disable the misused keys. confDKIM.Sign = []string{year + "a", year + "b"} confDomain := config.Domain{ ClientSettingsDomain: "mail." + domain.Name(), LocalpartCatchallSeparator: "+", DKIM: confDKIM, DMARC: &config.DMARC{ Account: accountName, Localpart: "dmarc-reports", Mailbox: "DMARC", }, TLSRPT: &config.TLSRPT{ Account: accountName, Localpart: "tls-reports", Mailbox: "TLSRPT", }, } if withMTASTS { confDomain.MTASTS = &config.MTASTS{ PolicyID: time.Now().UTC().Format("20060102T150405"), Mode: mtasts.ModeEnforce, // We start out with 24 hour, and warn in the admin interface that users should // increase it to weeks once the setup works. MaxAge: 24 * time.Hour, MX: []string{hostname.ASCII}, } } rpaths := paths paths = nil return confDomain, rpaths, nil } // DKIMAdd adds a DKIM selector for a domain, generating a key and writing it to disk. func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding dkim key", rerr, slog.Any("domain", domain), slog.Any("selector", selector)) } }() switch hash { case "sha256", "sha1": default: return fmt.Errorf("%w: unknown hash algorithm %q", ErrRequest, hash) } var privKey []byte var err error var kind string switch algorithm { case "rsa": privKey, err = MakeDKIMRSAKey(selector, domain) kind = "rsa2048" case "ed25519": privKey, err = MakeDKIMEd25519Key(selector, domain) kind = "ed25519" default: err = fmt.Errorf("unknown algorithm") } if err != nil { return fmt.Errorf("%w: making dkim key: %v", ErrRequest, err) } // Only take lock now, we don't want to hold it while generating a key. defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic d, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) } if _, ok := d.DKIM.Selectors[selector.Name()]; ok { return fmt.Errorf("%w: selector already exists for domain", ErrRequest) } record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII) timestamp := time.Now().Format("20060102T150405") keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind)) p := mox.ConfigDynamicDirPath(keyPath) if err := writeFile(log, p, privKey); err != nil { return fmt.Errorf("writing key file: %v", err) } removePath := p defer func() { if removePath != "" { err := os.Remove(removePath) log.Check(err, "removing path for dkim key", slog.String("path", removePath)) } }() nsel := config.Selector{ Hash: hash, Canonicalization: config.Canonicalization{ HeaderRelaxed: headerRelaxed, BodyRelaxed: bodyRelaxed, }, Headers: headers, DontSealHeaders: !seal, Expiration: lifetime.String(), PrivateKeyFile: keyPath, } // All good, time to update the config. nd := d nd.DKIM.Selectors = map[string]config.Selector{} for name, osel := range d.DKIM.Selectors { nd.DKIM.Selectors[name] = osel } nd.DKIM.Selectors[selector.Name()] = nsel nc := c nc.Domains = map[string]config.Domain{} for name, dom := range c.Domains { nc.Domains[name] = dom } nc.Domains[domain.Name()] = nd if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("dkim key added", slog.Any("domain", domain), slog.Any("selector", selector)) removePath = "" // Prevent cleanup of key file. return nil } // DKIMRemove removes the selector from the domain, moving the key file out of the way. func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("removing dkim key", rerr, slog.Any("domain", domain), slog.Any("selector", selector)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic d, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) } sel, ok := d.DKIM.Selectors[selector.Name()] if !ok { return fmt.Errorf("%w: selector does not exist for domain", ErrRequest) } nsels := map[string]config.Selector{} for name, sel := range d.DKIM.Selectors { if name != selector.Name() { nsels[name] = sel } } nsign := make([]string, 0, len(d.DKIM.Sign)) for _, name := range d.DKIM.Sign { if name != selector.Name() { nsign = append(nsign, name) } } nd := d nd.DKIM = config.DKIM{Selectors: nsels, Sign: nsign} nc := c nc.Domains = map[string]config.Domain{} for name, dom := range c.Domains { nc.Domains[name] = dom } nc.Domains[domain.Name()] = nd if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } // Move away a DKIM private key to a subdirectory "old". But only if // not in use by other domains. usedKeyPaths := gatherUsedKeysPaths(nc) moveAwayKeys(log, map[string]config.Selector{selector.Name(): sel}, usedKeyPaths) log.Info("dkim key removed", slog.Any("domain", domain), slog.Any("selector", selector)) return nil } // DomainAdd adds the domain to the domains config, rewriting domains.conf and // marking it loaded. // // accountName is used for DMARC/TLS report and potentially for the postmaster address. // If the account does not exist, it is created with localpart. Localpart must be // set only if the account does not yet exist. func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding domain", rerr, slog.Any("domain", domain), slog.String("account", accountName), slog.Any("localpart", localpart)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic if _, ok := c.Domains[domain.Name()]; ok { return fmt.Errorf("%w: domain already present", ErrRequest) } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Domains = map[string]config.Domain{} for name, d := range c.Domains { nc.Domains[name] = d } // Only enable mta-sts for domain if there is a listener with mta-sts. var withMTASTS bool for _, l := range mox.Conf.Static.Listeners { if l.MTASTSHTTPS.Enabled { withMTASTS = true break } } confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS) if err != nil { return fmt.Errorf("preparing domain config: %v", err) } defer func() { for _, f := range cleanupFiles { err := os.Remove(f) log.Check(err, "cleaning up file after error", slog.String("path", f)) } }() if _, ok := c.Accounts[accountName]; ok && localpart != "" { return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest) } else if !ok && localpart == "" { return fmt.Errorf("%w: account does not yet exist (specify a localpart)", ErrRequest) } else if accountName == "" { return fmt.Errorf("%w: account name is empty", ErrRequest) } else if !ok { nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain)) } else if accountName != mox.Conf.Static.Postmaster.Account { nacc := nc.Accounts[accountName] nd := map[string]config.Destination{} for k, v := range nacc.Destinations { nd[k] = v } pmaddr := smtp.NewAddress("postmaster", domain) nd[pmaddr.String()] = config.Destination{} nacc.Destinations = nd nc.Accounts[accountName] = nacc } nc.Domains[domain.Name()] = confDomain if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("domain added", slog.Any("domain", domain)) cleanupFiles = nil // All good, don't cleanup. return nil } // DomainRemove removes domain from the config, rewriting domains.conf. // // No accounts are removed, also not when they still reference this domain. func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("removing domain", rerr, slog.Any("domain", domain)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic domConf, ok := c.Domains[domain.Name()] if !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) } // Check that the domain isn't referenced in a TLS public key. tlspubkeys, err := store.TLSPublicKeyList(ctx, "") if err != nil { return fmt.Errorf("%w: listing tls public keys: %s", ErrRequest, err) } atdom := "@" + domain.Name() for _, tpk := range tlspubkeys { if strings.HasSuffix(tpk.LoginAddress, atdom) { return fmt.Errorf("%w: domain is still referenced in tls public key by login address %q of account %q, change or remove it first", ErrRequest, tpk.LoginAddress, tpk.Account) } } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Domains = map[string]config.Domain{} s := domain.Name() for name, d := range c.Domains { if name != s { nc.Domains[name] = d } } if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } // Move away any DKIM private keys to a subdirectory "old". But only if // they are not in use by other domains. usedKeyPaths := gatherUsedKeysPaths(nc) moveAwayKeys(log, domConf.DKIM.Selectors, usedKeyPaths) log.Info("domain removed", slog.Any("domain", domain)) return nil } func gatherUsedKeysPaths(nc config.Dynamic) map[string]bool { usedKeyPaths := map[string]bool{} for _, dc := range nc.Domains { for _, sel := range dc.DKIM.Selectors { usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true } } return usedKeyPaths } func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths map[string]bool) { for _, sel := range sels { if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] { continue } src := mox.ConfigDirPath(sel.PrivateKeyFile) dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile))) _, err := os.Stat(dst) if err == nil { err = fmt.Errorf("destination already exists") } else if os.IsNotExist(err) { os.MkdirAll(filepath.Dir(dst), 0770) err = os.Rename(src, dst) } if err != nil { log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst)) } } } // DomainSave calls xmodify with a shallow copy of the domain config. xmodify // can modify the config, but must clone all referencing data it changes. // xmodify may employ panic-based error handling. After xmodify returns, the // modified config is verified, saved and takes effect. func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain) error) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("saving domain config", rerr) } }() defer mox.Conf.DynamicLockUnlock()() nc := mox.Conf.Dynamic // Shallow copy. dom, ok := nc.Domains[domainName] // dom is a shallow copy. if !ok { return fmt.Errorf("%w: domain not present", ErrRequest) } if err := xmodify(&dom); err != nil { return err } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc.Domains = map[string]config.Domain{} for name, d := range mox.Conf.Dynamic.Domains { nc.Domains[name] = d } nc.Domains[domainName] = dom if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("domain saved") return nil } // ConfigSave calls xmodify with a shallow copy of the dynamic config. xmodify // can modify the config, but must clone all referencing data it changes. // xmodify may employ panic-based error handling. After xmodify returns, the // modified config is verified, saved and takes effect. func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("saving config", rerr) } }() defer mox.Conf.DynamicLockUnlock()() nc := mox.Conf.Dynamic // Shallow copy. xmodify(&nc) if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("config saved") return nil } // 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 // delivered. // // Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd. func AccountAdd(ctx context.Context, account, address string) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding account", rerr, slog.String("account", account), slog.String("address", address)) } }() addr, err := smtp.ParseAddress(address) if err != nil { return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err) } defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic if _, ok := c.Accounts[account]; ok { return fmt.Errorf("%w: account already present", ErrRequest) } if err := checkAddressAvailable(addr); err != nil { return fmt.Errorf("%w: address not available: %v", ErrRequest, err) } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Accounts = map[string]config.Account{} for name, a := range c.Accounts { nc.Accounts[name] = a } nc.Accounts[account] = MakeAccountConfig(addr) if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("account added", slog.String("account", account), slog.Any("address", addr)) return nil } // AccountRemove removes an account and reloads the configuration. func AccountRemove(ctx context.Context, account string) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding account", rerr, slog.String("account", account)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic if _, ok := c.Accounts[account]; !ok { return fmt.Errorf("%w: account does not exist", ErrRequest) } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Accounts = map[string]config.Account{} for name, a := range c.Accounts { if name != account { nc.Accounts[name] = a } } if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } odir := filepath.Join(mox.DataDirPath("accounts"), account) tmpdir := filepath.Join(mox.DataDirPath("tmp"), "oldaccount-"+account) if err := os.Rename(odir, tmpdir); err != nil { 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) } if err := os.RemoveAll(tmpdir); err != nil { log.Errorx("removing old account data directory", err, slog.String("account", account)) return fmt.Errorf("account removed, its data directory moved to %q, but removing failed: %v", odir, err) } if err := store.TLSPublicKeyRemoveForAccount(context.Background(), account); err != nil { log.Errorx("removing tls public keys for removed account", err) return fmt.Errorf("account removed, but removing tls public keys failed: %v", err) } log.Info("account removed", slog.String("account", account)) return nil } // checkAddressAvailable checks that the address after canonicalization is not // already configured, and that its localpart does not contain the catchall // localpart separator. // // Must be called with config lock held. func checkAddressAvailable(addr smtp.Address) error { dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()] if !ok { return fmt.Errorf("domain does not exist") } lp := mox.CanonicalLocalpart(addr.Localpart, dc) 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)) } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) { return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator) } else if _, ok := dc.Aliases[lp.String()]; ok { return fmt.Errorf("address in use as alias") } return nil } // AddressAdd adds an email address to an account and reloads the configuration. If // address starts with an @ it is treated as a catchall address for the domain. func AddressAdd(ctx context.Context, address, account string) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding address", rerr, slog.String("address", address), slog.String("account", account)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic a, ok := c.Accounts[account] if !ok { return fmt.Errorf("%w: account does not exist", ErrRequest) } var destAddr string if strings.HasPrefix(address, "@") { d, err := dns.ParseDomain(address[1:]) if err != nil { return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err) } dname := d.Name() destAddr = "@" + dname if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok { return fmt.Errorf("%w: domain does not exist", ErrRequest) } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok { return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest) } } else { addr, err := smtp.ParseAddress(address) if err != nil { return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err) } if err := checkAddressAvailable(addr); err != nil { return fmt.Errorf("%w: address not available: %v", ErrRequest, err) } destAddr = addr.String() } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Accounts = map[string]config.Account{} for name, a := range c.Accounts { nc.Accounts[name] = a } nd := map[string]config.Destination{} for name, d := range a.Destinations { nd[name] = d } nd[destAddr] = config.Destination{} a.Destinations = nd nc.Accounts[account] = a if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("address added", slog.String("address", address), slog.String("account", account)) return nil } // AddressRemove removes an email address and reloads the configuration. // Address can be a catchall address for the domain of the form "@". // // If the address is member of an alias, remove it from from the alias, unless it // is the last member. func AddressRemove(ctx context.Context, address string) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("removing address", rerr, slog.String("address", address)) } }() defer mox.Conf.DynamicLockUnlock()() ad, ok := mox.Conf.AccountDestinationsLocked[address] if !ok { return fmt.Errorf("%w: address does not exists", ErrRequest) } // Compose new config without modifying existing data structures. If we fail, we // leave no trace. a, ok := mox.Conf.Dynamic.Accounts[ad.Account] if !ok { return fmt.Errorf("internal error: cannot find account") } na := a na.Destinations = map[string]config.Destination{} var dropped bool for destAddr, d := range a.Destinations { if destAddr != address { na.Destinations[destAddr] = d } else { dropped = true } } if !dropped { return fmt.Errorf("%w: address not removed, likely a postmaster/reporting address", ErrRequest) } // Also remove matching address from FromIDLoginAddresses, composing a new slice. // Refuse if address is referenced in a TLS public key. var dom dns.Domain var pa smtp.Address // For non-catchall addresses (most). var err error if strings.HasPrefix(address, "@") { dom, err = dns.ParseDomain(address[1:]) if err != nil { return fmt.Errorf("%w: parsing domain for catchall address: %v", ErrRequest, err) } } else { pa, err = smtp.ParseAddress(address) if err != nil { return fmt.Errorf("%w: parsing address: %v", ErrRequest, err) } dom = pa.Domain } dc, ok := mox.Conf.Dynamic.Domains[dom.Name()] if !ok { return fmt.Errorf("%w: unknown domain in address %q", ErrRequest, address) } var fromIDLoginAddresses []string for i, fa := range a.ParsedFromIDLoginAddresses { if fa.Domain != dom { // Keep for different domain. fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i]) continue } if strings.HasPrefix(address, "@") { continue } flp := mox.CanonicalLocalpart(fa.Localpart, dc) alp := mox.CanonicalLocalpart(pa.Localpart, dc) if alp != flp { // Keep for different localpart. fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i]) } } na.FromIDLoginAddresses = fromIDLoginAddresses // Refuse if there is still a TLS public key that references this address. tlspubkeys, err := store.TLSPublicKeyList(ctx, ad.Account) if err != nil { return fmt.Errorf("%w: listing tls public keys for account: %v", ErrRequest, err) } for _, tpk := range tlspubkeys { a, err := smtp.ParseAddress(tpk.LoginAddress) if err != nil { return fmt.Errorf("%w: parsing address from tls public key: %v", ErrRequest, err) } lp := mox.CanonicalLocalpart(a.Localpart, dc) ca := smtp.NewAddress(lp, a.Domain) if xad, ok := mox.Conf.AccountDestinationsLocked[ca.String()]; ok && xad.Localpart == ad.Localpart { return fmt.Errorf("%w: tls public key %q references this address as login address %q, remove the tls public key before removing the address", ErrRequest, tpk.Fingerprint, tpk.LoginAddress) } } // And remove as member from aliases configured in domains. domains := maps.Clone(mox.Conf.Dynamic.Domains) for _, aa := range na.Aliases { if aa.SubscriptionAddress != address { continue } aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name()) dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()] if !ok { return fmt.Errorf("cannot find domain for alias %s", aliasAddr) } a, ok := dom.Aliases[aa.Alias.LocalpartStr] if !ok { return fmt.Errorf("cannot find alias %s", aliasAddr) } a.Addresses = slices.Clone(a.Addresses) a.Addresses = slices.DeleteFunc(a.Addresses, func(v string) bool { return v == address }) if len(a.Addresses) == 0 { return fmt.Errorf("address is last member of alias %s, add new members or remove alias first", aliasAddr) } a.ParsedAddresses = nil // Filled when parsing config. dom.Aliases = maps.Clone(dom.Aliases) dom.Aliases[aa.Alias.LocalpartStr] = a domains[aa.Alias.Domain.Name()] = dom } na.Aliases = nil // Filled when parsing config. nc := mox.Conf.Dynamic nc.Accounts = map[string]config.Account{} for name, a := range mox.Conf.Dynamic.Accounts { nc.Accounts[name] = a } nc.Accounts[ad.Account] = na nc.Domains = domains if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account)) return nil } func AliasAdd(ctx context.Context, addr smtp.Address, alias config.Alias) error { return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { if _, ok := d.Aliases[addr.Localpart.String()]; ok { return fmt.Errorf("%w: alias already present", ErrRequest) } if d.Aliases == nil { d.Aliases = map[string]config.Alias{} } d.Aliases = maps.Clone(d.Aliases) d.Aliases[addr.Localpart.String()] = alias return nil }) } func AliasUpdate(ctx context.Context, addr smtp.Address, alias config.Alias) error { return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { a, ok := d.Aliases[addr.Localpart.String()] if !ok { return fmt.Errorf("%w: alias does not exist", ErrRequest) } a.PostPublic = alias.PostPublic a.ListMembers = alias.ListMembers a.AllowMsgFrom = alias.AllowMsgFrom d.Aliases = maps.Clone(d.Aliases) d.Aliases[addr.Localpart.String()] = a return nil }) } func AliasRemove(ctx context.Context, addr smtp.Address) error { return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { _, ok := d.Aliases[addr.Localpart.String()] if !ok { return fmt.Errorf("%w: alias does not exist", ErrRequest) } d.Aliases = maps.Clone(d.Aliases) delete(d.Aliases, addr.Localpart.String()) return nil }) } func AliasAddressesAdd(ctx context.Context, addr smtp.Address, addresses []string) error { if len(addresses) == 0 { return fmt.Errorf("%w: at least one address required", ErrRequest) } return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { alias, ok := d.Aliases[addr.Localpart.String()] if !ok { return fmt.Errorf("%w: no such alias", ErrRequest) } alias.Addresses = append(slices.Clone(alias.Addresses), addresses...) alias.ParsedAddresses = nil d.Aliases = maps.Clone(d.Aliases) d.Aliases[addr.Localpart.String()] = alias return nil }) } func AliasAddressesRemove(ctx context.Context, addr smtp.Address, addresses []string) error { if len(addresses) == 0 { return fmt.Errorf("%w: need at least one address", ErrRequest) } return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error { alias, ok := d.Aliases[addr.Localpart.String()] if !ok { return fmt.Errorf("%w: no such alias", ErrRequest) } alias.Addresses = slices.DeleteFunc(slices.Clone(alias.Addresses), func(addr string) bool { n := len(addresses) addresses = slices.DeleteFunc(addresses, func(a string) bool { return a == addr }) return n > len(addresses) }) if len(addresses) > 0 { return fmt.Errorf("%w: address not found: %s", ErrRequest, strings.Join(addresses, ", ")) } alias.ParsedAddresses = nil d.Aliases = maps.Clone(d.Aliases) d.Aliases[addr.Localpart.String()] = alias return nil }) } // AccountSave updates the configuration of an account. Function xmodify is called // with a shallow copy of the current configuration of the account. It must not // change referencing fields (e.g. existing slice/map/pointer), they may still be // in use, and the change may be rolled back. Referencing values must be copied and // replaced by the modify. The function may raise a panic for error handling. func AccountSave(ctx context.Context, account string, xmodify func(acc *config.Account)) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("saving account fields", rerr, slog.String("account", account)) } }() defer mox.Conf.DynamicLockUnlock()() c := mox.Conf.Dynamic acc, ok := c.Accounts[account] if !ok { return fmt.Errorf("%w: account not present", ErrRequest) } xmodify(&acc) // Compose new config without modifying existing data structures. If we fail, we // leave no trace. nc := c nc.Accounts = map[string]config.Account{} for name, a := range c.Accounts { nc.Accounts[name] = a } nc.Accounts[account] = acc if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } log.Info("account fields saved", slog.String("account", account)) return nil }