7 cryptorand "crypto/rand"
22 "github.com/mjl-/mox/config"
23 "github.com/mjl-/mox/dns"
24 "github.com/mjl-/mox/junk"
25 "github.com/mjl-/mox/mlog"
26 "github.com/mjl-/mox/mox-"
27 "github.com/mjl-/mox/mtasts"
28 "github.com/mjl-/mox/queue"
29 "github.com/mjl-/mox/smtp"
30 "github.com/mjl-/mox/store"
33var pkglog = mlog.New("admin", nil)
35var ErrRequest = errors.New("bad request")
37// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
39// selector and domain can be empty. If not, they are used in the note.
40func MakeDKIMEd25519Key(selector, domain dns.Domain) ([]byte, error) {
41 _, privKey, err := ed25519.GenerateKey(cryptorand.Reader)
43 return nil, fmt.Errorf("generating key: %w", err)
46 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
48 return nil, fmt.Errorf("marshal key: %w", err)
53 Headers: map[string]string{
54 "Note": dkimKeyNote("ed25519", selector, domain),
59 if err := pem.Encode(b, block); err != nil {
60 return nil, fmt.Errorf("encoding pem: %w", err)
65func dkimKeyNote(kind string, selector, domain dns.Domain) string {
66 s := kind + " dkim private key"
68 if selector != zero && domain != zero {
69 s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII)
71 s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339))
75// MakeDKIMRSAKey returns a PEM buffer containing an rsa key for use with
77// selector and domain can be empty. If not, they are used in the note.
78func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
79 // 2048 bits seems reasonable in 2022, 1024 is on the low side, larger
80 // keys may not fit in UDP DNS response.
81 privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
83 return nil, fmt.Errorf("generating key: %w", err)
86 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
88 return nil, fmt.Errorf("marshal key: %w", err)
93 Headers: map[string]string{
94 "Note": dkimKeyNote("rsa-2048", selector, domain),
99 if err := pem.Encode(b, block); err != nil {
100 return nil, fmt.Errorf("encoding pem: %w", err)
102 return b.Bytes(), nil
105// MakeAccountConfig returns a new account configuration for an email address.
106func MakeAccountConfig(addr smtp.Address) config.Account {
107 account := config.Account{
108 Domain: addr.Domain.Name(),
109 Destinations: map[string]config.Destination{
112 RejectsMailbox: "Rejects",
113 JunkFilter: &config.JunkFilter{
123 NoCustomPassword: true,
125 account.AutomaticJunkFlags.Enabled = true
126 account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
127 account.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
128 account.SubjectPass.Period = 12 * time.Hour
132func writeFile(log mlog.Log, path string, data []byte) error {
133 os.MkdirAll(filepath.Dir(path), 0770)
135 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
137 return fmt.Errorf("creating file %s: %s", path, err)
142 log.Check(err, "closing file after error")
143 err = os.Remove(path)
144 log.Check(err, "removing file after error", slog.String("path", path))
147 if _, err := f.Write(data); err != nil {
148 return fmt.Errorf("writing file %s: %s", path, err)
150 if err := f.Close(); err != nil {
151 return fmt.Errorf("close file: %v", err)
157// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
158// accountName for DMARC and TLS reports.
159func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
160 log := pkglog.WithContext(ctx)
163 year := now.Format("2006")
164 timestamp := now.Format("20060102T150405")
168 for _, p := range paths {
170 log.Check(err, "removing path for domain config", slog.String("path", p))
174 confDKIM := config.DKIM{
175 Selectors: map[string]config.Selector{},
178 addSelector := func(kind, name string, privKey []byte) error {
179 record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
180 keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
181 p := mox.ConfigDynamicDirPath(keyPath)
182 if err := writeFile(log, p, privKey); err != nil {
185 paths = append(paths, p)
186 confDKIM.Selectors[name] = config.Selector{
189 // Messages in the wild have been observed with 2 hours and 1 year expiration.
191 PrivateKeyFile: keyPath,
196 addRSA := func(name string) error {
197 key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain)
199 return fmt.Errorf("making dkim rsa private key: %s", err)
201 return addSelector("rsa2048", name, key)
204 if err := addRSA(year + "a"); err != nil {
205 return config.Domain{}, nil, err
207 if err := addRSA(year + "b"); err != nil {
208 return config.Domain{}, nil, err
211 // We sign with the first two. In case they are misused, the switch to the other
212 // keys is easy, just change the config. Operators should make the public key field
213 // of the misused keys empty in the DNS records to disable the misused keys.
214 confDKIM.Sign = []string{year + "a"}
216 confDomain := config.Domain{
217 ClientSettingsDomain: "mail." + domain.Name(),
218 LocalpartCatchallSeparator: "+",
220 DMARC: &config.DMARC{
221 Account: accountName,
222 Localpart: "dmarcreports",
225 TLSRPT: &config.TLSRPT{
226 Account: accountName,
227 Localpart: "tlsreports",
233 confDomain.MTASTS = &config.MTASTS{
234 PolicyID: time.Now().UTC().Format("20060102T150405"),
235 Mode: mtasts.ModeEnforce,
236 // We start out with 24 hour, and warn in the admin interface that users should
237 // increase it to weeks once the setup works.
238 MaxAge: 24 * time.Hour,
239 MX: []string{hostname.ASCII},
246 return confDomain, rpaths, nil
249// DKIMAdd adds a DKIM selector for a domain, generating a key and writing it to disk.
250func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) (rerr error) {
251 log := pkglog.WithContext(ctx)
254 log.Errorx("adding dkim key", rerr,
255 slog.Any("domain", domain),
256 slog.Any("selector", selector))
261 case "sha256", "sha1":
263 return fmt.Errorf("%w: unknown hash algorithm %q", ErrRequest, hash)
271 privKey, err = MakeDKIMRSAKey(selector, domain)
274 privKey, err = MakeDKIMEd25519Key(selector, domain)
277 err = fmt.Errorf("unknown algorithm")
280 return fmt.Errorf("%w: making dkim key: %v", ErrRequest, err)
283 // Only take lock now, we don't want to hold it while generating a key.
284 defer mox.Conf.DynamicLockUnlock()()
286 c := mox.Conf.Dynamic
287 d, ok := c.Domains[domain.Name()]
289 return fmt.Errorf("%w: domain does not exist", ErrRequest)
292 if _, ok := d.DKIM.Selectors[selector.Name()]; ok {
293 return fmt.Errorf("%w: selector already exists for domain", ErrRequest)
296 record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
297 timestamp := time.Now().Format("20060102T150405")
298 keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
299 p := mox.ConfigDynamicDirPath(keyPath)
300 if err := writeFile(log, p, privKey); err != nil {
301 return fmt.Errorf("writing key file: %v", err)
305 if removePath != "" {
306 err := os.Remove(removePath)
307 log.Check(err, "removing path for dkim key", slog.String("path", removePath))
311 nsel := config.Selector{
313 Canonicalization: config.Canonicalization{
314 HeaderRelaxed: headerRelaxed,
315 BodyRelaxed: bodyRelaxed,
318 DontSealHeaders: !seal,
319 Expiration: lifetime.String(),
320 PrivateKeyFile: keyPath,
323 // All good, time to update the config.
325 nd.DKIM.Selectors = map[string]config.Selector{}
326 maps.Copy(nd.DKIM.Selectors, d.DKIM.Selectors)
327 nd.DKIM.Selectors[selector.Name()] = nsel
329 nc.Domains = map[string]config.Domain{}
330 maps.Copy(nc.Domains, c.Domains)
331 nc.Domains[domain.Name()] = nd
333 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
334 return fmt.Errorf("writing domains.conf: %w", err)
337 log.Info("dkim key added", slog.Any("domain", domain), slog.Any("selector", selector))
338 removePath = "" // Prevent cleanup of key file.
342// DKIMRemove removes the selector from the domain, moving the key file out of the way.
343func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
344 log := pkglog.WithContext(ctx)
347 log.Errorx("removing dkim key", rerr,
348 slog.Any("domain", domain),
349 slog.Any("selector", selector))
353 defer mox.Conf.DynamicLockUnlock()()
355 c := mox.Conf.Dynamic
356 d, ok := c.Domains[domain.Name()]
358 return fmt.Errorf("%w: domain does not exist", ErrRequest)
361 sel, ok := d.DKIM.Selectors[selector.Name()]
363 return fmt.Errorf("%w: selector does not exist for domain", ErrRequest)
366 nsels := map[string]config.Selector{}
367 for name, sel := range d.DKIM.Selectors {
368 if name != selector.Name() {
372 nsign := make([]string, 0, len(d.DKIM.Sign))
373 for _, name := range d.DKIM.Sign {
374 if name != selector.Name() {
375 nsign = append(nsign, name)
380 nd.DKIM = config.DKIM{Selectors: nsels, Sign: nsign}
382 nc.Domains = map[string]config.Domain{}
383 maps.Copy(nc.Domains, c.Domains)
384 nc.Domains[domain.Name()] = nd
386 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
387 return fmt.Errorf("writing domains.conf: %w", err)
390 // Move away a DKIM private key to a subdirectory "old". But only if
391 // not in use by other domains.
392 usedKeyPaths := gatherUsedKeysPaths(nc)
393 moveAwayKeys(log, map[string]config.Selector{selector.Name(): sel}, usedKeyPaths)
395 log.Info("dkim key removed", slog.Any("domain", domain), slog.Any("selector", selector))
399// DomainAdd adds the domain to the domains config, rewriting domains.conf and
402// accountName is used for DMARC/TLS report and potentially for the postmaster address.
403// If the account does not exist, it is created with localpart. Localpart must be
404// set only if the account does not yet exist.
405func DomainAdd(ctx context.Context, disabled bool, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) {
406 log := pkglog.WithContext(ctx)
409 log.Errorx("adding domain", rerr,
410 slog.Any("disabled", disabled),
411 slog.Any("domain", domain),
412 slog.String("account", accountName),
413 slog.Any("localpart", localpart))
417 defer mox.Conf.DynamicLockUnlock()()
419 c := mox.Conf.Dynamic
420 if _, ok := c.Domains[domain.Name()]; ok {
421 return fmt.Errorf("%w: domain already present", ErrRequest)
424 // Compose new config without modifying existing data structures. If we fail, we
427 nc.Domains = map[string]config.Domain{}
428 maps.Copy(nc.Domains, c.Domains)
430 // Only enable mta-sts for domain if there is a listener with mta-sts.
432 for _, l := range mox.Conf.Static.Listeners {
433 if l.MTASTSHTTPS.Enabled {
439 confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS)
441 return fmt.Errorf("preparing domain config: %v", err)
444 for _, f := range cleanupFiles {
446 log.Check(err, "cleaning up file after error", slog.String("path", f))
449 confDomain.Disabled = disabled
451 if _, ok := c.Accounts[accountName]; ok && localpart != "" {
452 return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest)
453 } else if !ok && localpart == "" {
454 return fmt.Errorf("%w: account does not yet exist (specify a localpart)", ErrRequest)
455 } else if accountName == "" {
456 return fmt.Errorf("%w: account name is empty", ErrRequest)
458 nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain))
459 } else if accountName != mox.Conf.Static.Postmaster.Account {
460 nacc := nc.Accounts[accountName]
461 nd := map[string]config.Destination{}
462 maps.Copy(nd, nacc.Destinations)
463 pmaddr := smtp.NewAddress("postmaster", domain)
464 nd[pmaddr.String()] = config.Destination{}
465 nacc.Destinations = nd
466 nc.Accounts[accountName] = nacc
469 nc.Domains[domain.Name()] = confDomain
471 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
472 return fmt.Errorf("writing domains.conf: %w", err)
474 log.Info("domain added", slog.Any("domain", domain), slog.Bool("disabled", disabled))
475 cleanupFiles = nil // All good, don't cleanup.
479// DomainRemove removes domain from the config, rewriting domains.conf.
481// No accounts are removed, also not when they still reference this domain.
482func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
483 log := pkglog.WithContext(ctx)
486 log.Errorx("removing domain", rerr, slog.Any("domain", domain))
490 defer mox.Conf.DynamicLockUnlock()()
492 c := mox.Conf.Dynamic
493 domConf, ok := c.Domains[domain.Name()]
495 return fmt.Errorf("%w: domain does not exist", ErrRequest)
498 // Check that the domain isn't referenced in a TLS public key.
499 tlspubkeys, err := store.TLSPublicKeyList(ctx, "")
501 return fmt.Errorf("%w: listing tls public keys: %s", ErrRequest, err)
503 atdom := "@" + domain.Name()
504 for _, tpk := range tlspubkeys {
505 if strings.HasSuffix(tpk.LoginAddress, atdom) {
506 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)
510 // Compose new config without modifying existing data structures. If we fail, we
513 nc.Domains = map[string]config.Domain{}
515 for name, d := range c.Domains {
521 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
522 return fmt.Errorf("writing domains.conf: %w", err)
525 // Move away any DKIM private keys to a subdirectory "old". But only if
526 // they are not in use by other domains.
527 usedKeyPaths := gatherUsedKeysPaths(nc)
528 moveAwayKeys(log, domConf.DKIM.Selectors, usedKeyPaths)
530 log.Info("domain removed", slog.Any("domain", domain))
534func gatherUsedKeysPaths(nc config.Dynamic) map[string]bool {
535 usedKeyPaths := map[string]bool{}
536 for _, dc := range nc.Domains {
537 for _, sel := range dc.DKIM.Selectors {
538 usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
544func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths map[string]bool) {
545 for _, sel := range sels {
546 if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
549 src := mox.ConfigDirPath(sel.PrivateKeyFile)
550 dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
551 _, err := os.Stat(dst)
553 err = fmt.Errorf("destination already exists")
554 } else if os.IsNotExist(err) {
555 os.MkdirAll(filepath.Dir(dst), 0770)
556 err = os.Rename(src, dst)
559 log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst))
564// DomainSave calls xmodify with a shallow copy of the domain config. xmodify
565// can modify the config, but must clone all referencing data it changes.
566// xmodify may employ panic-based error handling. After xmodify returns, the
567// modified config is verified, saved and takes effect.
568func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain) error) (rerr error) {
569 log := pkglog.WithContext(ctx)
572 log.Errorx("saving domain config", rerr)
576 defer mox.Conf.DynamicLockUnlock()()
578 nc := mox.Conf.Dynamic // Shallow copy.
579 dom, ok := nc.Domains[domainName] // dom is a shallow copy.
581 return fmt.Errorf("%w: domain not present", ErrRequest)
584 if err := xmodify(&dom); err != nil {
588 // Compose new config without modifying existing data structures. If we fail, we
590 nc.Domains = map[string]config.Domain{}
591 maps.Copy(nc.Domains, mox.Conf.Dynamic.Domains)
592 nc.Domains[domainName] = dom
594 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
595 return fmt.Errorf("writing domains.conf: %w", err)
598 log.Info("domain saved")
602// ConfigSave calls xmodify with a shallow copy of the dynamic config. xmodify
603// can modify the config, but must clone all referencing data it changes.
604// xmodify may employ panic-based error handling. After xmodify returns, the
605// modified config is verified, saved and takes effect.
606func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr error) {
607 log := pkglog.WithContext(ctx)
610 log.Errorx("saving config", rerr)
614 defer mox.Conf.DynamicLockUnlock()()
616 nc := mox.Conf.Dynamic // Shallow copy.
619 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
620 return fmt.Errorf("writing domains.conf: %w", err)
623 log.Info("config saved")
627// AccountAdd adds an account and an initial address and reloads the configuration.
629// The new account does not have a password, so cannot yet log in. Email can be
632// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd.
633func AccountAdd(ctx context.Context, account, address string) (rerr error) {
634 log := pkglog.WithContext(ctx)
637 log.Errorx("adding account", rerr, slog.String("account", account), slog.String("address", address))
641 addr, err := smtp.ParseAddress(address)
643 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
646 defer mox.Conf.DynamicLockUnlock()()
648 c := mox.Conf.Dynamic
649 if _, ok := c.Accounts[account]; ok {
650 return fmt.Errorf("%w: account already present", ErrRequest)
653 // Ensure the directory does not exist, e.g. due to pending account removal, or an
654 // otherwise failed cleanup.
655 accountDir := filepath.Join(mox.DataDirPath("accounts"), account)
656 if _, err := os.Stat(accountDir); err == nil {
657 return fmt.Errorf("%w: account directory %q already/still exists", ErrRequest, accountDir)
658 } else if !errors.Is(err, fs.ErrNotExist) {
659 return fmt.Errorf(`%w: stat account directory %q, expected "does not exist": %v`, ErrRequest, accountDir, err)
662 if err := checkAddressAvailable(addr); err != nil {
663 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
666 // Compose new config without modifying existing data structures. If we fail, we
669 nc.Accounts = map[string]config.Account{}
670 maps.Copy(nc.Accounts, c.Accounts)
671 nc.Accounts[account] = MakeAccountConfig(addr)
673 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
674 return fmt.Errorf("writing domains.conf: %w", err)
676 log.Info("account added", slog.String("account", account), slog.Any("address", addr))
680// AccountRemove removes an account and reloads the configuration.
681func AccountRemove(ctx context.Context, account string) (rerr error) {
682 log := pkglog.WithContext(ctx)
685 log.Errorx("adding account", rerr, slog.String("account", account))
689 // Open account now. The deferred Close MUST happen after the dynamic unlock,
690 // because during tests the consistency checker takes the same lock.
691 acc, err := store.OpenAccount(log, account, false)
693 return fmt.Errorf("%w: open account: %v", ErrRequest, err)
696 err := acc.SessionsClear(context.Background(), log)
697 log.Check(err, "clearing account login sessions")
700 log.Check(err, "closing account after error")
703 // Fail message and webhook deliveries from the queue for this account.
704 // Must be before the dynamic lock, since failing a message delivers a DSN to the
705 // account. We fail instead of drop because an error can still occur causing us to
707 nfailed, err := queue.Fail(ctx, log, queue.Filter{Account: account})
709 log.Info("failing queued messages for removed account", slog.Int("count", nfailed))
712 return fmt.Errorf("failing queued messages for account before removing: %v", err)
714 ncanceled, err := queue.HookCancel(ctx, log, queue.HookFilter{Account: account})
716 log.Info("canceling queued webhooks for removed account", slog.Int("count", ncanceled))
719 return fmt.Errorf("canceling queued webhooks for account before removing: %v", err)
722 // Cleanup suppressed addresses for account.
723 suppressions, err := queue.SuppressionList(ctx, account)
725 return fmt.Errorf("listing suppressed addresses for account: %v", err)
727 for _, sup := range suppressions {
728 addr, err := smtp.ParseAddress(sup.BaseAddress)
730 return fmt.Errorf("parsing suppressed address %q: %v", sup.BaseAddress, err)
732 if err := queue.SuppressionRemove(ctx, account, addr.Path()); err != nil {
733 return fmt.Errorf("removing suppression %q for account: %v", sup.BaseAddress, err)
737 defer mox.Conf.DynamicLockUnlock()()
739 c := mox.Conf.Dynamic
741 // Compose new config without modifying existing data structures. If we fail, we
744 nc.Accounts = map[string]config.Account{}
745 for name, a := range c.Accounts {
747 nc.Accounts[name] = a
751 // Write new config file.
752 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
753 return fmt.Errorf("writing domains.conf: %w", err)
756 // Mark files for account for removal as soon as all references have gone.
757 if err := acc.Remove(context.Background()); err != nil {
758 return fmt.Errorf("account removed from configuration file, but scheduling account directory for removal failed: %v", err)
761 log.Info("account marked for removal", slog.String("account", account))
765// checkAddressAvailable checks that the address after canonicalization is not
766// already configured, and that its localpart does not contain a catchall
767// localpart separator.
769// Must be called with config lock held.
770func checkAddressAvailable(addr smtp.Address) error {
771 dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()]
773 return fmt.Errorf("domain does not exist")
775 lp := mox.CanonicalLocalpart(addr.Localpart, dc)
776 if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok {
777 return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
779 for _, sep := range dc.LocalpartCatchallSeparatorsEffective {
780 if strings.Contains(string(addr.Localpart), sep) {
781 return fmt.Errorf("localpart cannot include domain catchall separator %s", sep)
784 if _, ok := dc.Aliases[lp.String()]; ok {
785 return fmt.Errorf("address in use as alias")
790// AddressAdd adds an email address to an account and reloads the configuration. If
791// address starts with an @ it is treated as a catchall address for the domain.
792func AddressAdd(ctx context.Context, address, account string) (rerr error) {
793 log := pkglog.WithContext(ctx)
796 log.Errorx("adding address", rerr, slog.String("address", address), slog.String("account", account))
800 defer mox.Conf.DynamicLockUnlock()()
802 c := mox.Conf.Dynamic
803 a, ok := c.Accounts[account]
805 return fmt.Errorf("%w: account does not exist", ErrRequest)
809 if strings.HasPrefix(address, "@") {
810 d, err := dns.ParseDomain(address[1:])
812 return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err)
815 destAddr = "@" + dname
816 if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok {
817 return fmt.Errorf("%w: domain does not exist", ErrRequest)
818 } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok {
819 return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
822 addr, err := smtp.ParseAddress(address)
824 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
827 if err := checkAddressAvailable(addr); err != nil {
828 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
830 destAddr = addr.String()
833 // Compose new config without modifying existing data structures. If we fail, we
836 nc.Accounts = map[string]config.Account{}
837 maps.Copy(nc.Accounts, c.Accounts)
838 nd := map[string]config.Destination{}
839 maps.Copy(nd, a.Destinations)
840 nd[destAddr] = config.Destination{}
842 nc.Accounts[account] = a
844 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
845 return fmt.Errorf("writing domains.conf: %w", err)
847 log.Info("address added", slog.String("address", address), slog.String("account", account))
851// AddressRemove removes an email address and reloads the configuration.
852// Address can be a catchall address for the domain of the form "@<domain>".
854// If the address is member of an alias, remove it from from the alias, unless it
855// is the last member.
856func AddressRemove(ctx context.Context, address string) (rerr error) {
857 log := pkglog.WithContext(ctx)
860 log.Errorx("removing address", rerr, slog.String("address", address))
864 defer mox.Conf.DynamicLockUnlock()()
866 ad, ok := mox.Conf.AccountDestinationsLocked[address]
868 return fmt.Errorf("%w: address does not exists", ErrRequest)
871 // Compose new config without modifying existing data structures. If we fail, we
873 a, ok := mox.Conf.Dynamic.Accounts[ad.Account]
875 return fmt.Errorf("internal error: cannot find account")
878 na.Destinations = map[string]config.Destination{}
880 for destAddr, d := range a.Destinations {
881 if destAddr != address {
882 na.Destinations[destAddr] = d
888 return fmt.Errorf("%w: address not removed, likely a postmaster/reporting address", ErrRequest)
891 // Also remove matching address from FromIDLoginAddresses, composing a new slice.
892 // Refuse if address is referenced in a TLS public key.
894 var pa smtp.Address // For non-catchall addresses (most).
896 if strings.HasPrefix(address, "@") {
897 dom, err = dns.ParseDomain(address[1:])
899 return fmt.Errorf("%w: parsing domain for catchall address: %v", ErrRequest, err)
902 pa, err = smtp.ParseAddress(address)
904 return fmt.Errorf("%w: parsing address: %v", ErrRequest, err)
908 dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
910 return fmt.Errorf("%w: unknown domain in address %q", ErrRequest, address)
913 var fromIDLoginAddresses []string
914 for i, fa := range a.ParsedFromIDLoginAddresses {
915 if fa.Domain != dom {
916 // Keep for different domain.
917 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
920 if strings.HasPrefix(address, "@") {
923 flp := mox.CanonicalLocalpart(fa.Localpart, dc)
924 alp := mox.CanonicalLocalpart(pa.Localpart, dc)
926 // Keep for different localpart.
927 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
930 na.FromIDLoginAddresses = fromIDLoginAddresses
932 // Refuse if there is still a TLS public key that references this address.
933 tlspubkeys, err := store.TLSPublicKeyList(ctx, ad.Account)
935 return fmt.Errorf("%w: listing tls public keys for account: %v", ErrRequest, err)
937 for _, tpk := range tlspubkeys {
938 a, err := smtp.ParseAddress(tpk.LoginAddress)
940 return fmt.Errorf("%w: parsing address from tls public key: %v", ErrRequest, err)
942 lp := mox.CanonicalLocalpart(a.Localpart, dc)
943 ca := smtp.NewAddress(lp, a.Domain)
944 if xad, ok := mox.Conf.AccountDestinationsLocked[ca.String()]; ok && xad.Localpart == ad.Localpart {
945 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)
949 // And remove as member from aliases configured in domains.
950 domains := maps.Clone(mox.Conf.Dynamic.Domains)
951 for _, aa := range na.Aliases {
952 if aa.SubscriptionAddress != address {
956 aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
958 dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
960 return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
962 a, ok := dom.Aliases[aa.Alias.LocalpartStr]
964 return fmt.Errorf("cannot find alias %s", aliasAddr)
966 a.Addresses = slices.Clone(a.Addresses)
967 a.Addresses = slices.DeleteFunc(a.Addresses, func(v string) bool { return v == address })
968 if len(a.Addresses) == 0 {
969 return fmt.Errorf("address is last member of alias %s, add new members or remove alias first", aliasAddr)
971 a.ParsedAddresses = nil // Filled when parsing config.
972 dom.Aliases = maps.Clone(dom.Aliases)
973 dom.Aliases[aa.Alias.LocalpartStr] = a
974 domains[aa.Alias.Domain.Name()] = dom
976 na.Aliases = nil // Filled when parsing config.
978 // Check that no message in the queue is for this address. The new account config
979 // must still match this address.
980 msgs, err := queue.List(ctx, queue.Filter{Account: ad.Account}, queue.Sort{})
982 return fmt.Errorf("listing messages in queue for account: %v", err)
984 for _, m := range msgs {
985 dc, ok := mox.Conf.Dynamic.Domains[m.SenderDomainStr]
987 return fmt.Errorf("%w: unknown sender domain %q in queued message", ErrRequest, m.SenderDomainStr)
989 lp := mox.CanonicalLocalpart(m.SenderLocalpart, dc)
990 sa := smtp.NewAddress(lp, m.SenderDomain.Domain).String()
991 if strings.HasPrefix(address, "@") {
992 // We are removing the catchall address. The queued message sender address must be
993 // configured explicitly to still belong to the account.
994 if xad, ok := mox.Conf.AccountDestinationsLocked[sa]; !ok || xad.Account != ad.Account {
995 return fmt.Errorf("%w: message delivery queue contains message with sender address %q that depends on the catchall address, drop message from queue first", ErrRequest, sa)
998 // We are removing a regular address. If the queued message matches the address,
999 // the catchall address must be configured for this account.
1000 if xad, ok := mox.Conf.AccountDestinationsLocked["@"+m.SenderDomainStr]; (!ok || xad.Account != ad.Account) && sa == address {
1001 return fmt.Errorf("%w: message delivery queue contains message with sender address %q and no catchall address is configured, drop message from queue first", ErrRequest, sa)
1006 nc := mox.Conf.Dynamic
1007 nc.Accounts = map[string]config.Account{}
1008 maps.Copy(nc.Accounts, mox.Conf.Dynamic.Accounts)
1009 nc.Accounts[ad.Account] = na
1010 nc.Domains = domains
1012 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
1013 return fmt.Errorf("writing domains.conf: %w", err)
1015 log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
1019func AliasAdd(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1020 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1021 if _, ok := d.Aliases[addr.Localpart.String()]; ok {
1022 return fmt.Errorf("%w: alias already present", ErrRequest)
1024 if d.Aliases == nil {
1025 d.Aliases = map[string]config.Alias{}
1027 d.Aliases = maps.Clone(d.Aliases)
1028 d.Aliases[addr.Localpart.String()] = alias
1033func AliasUpdate(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1034 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1035 a, ok := d.Aliases[addr.Localpart.String()]
1037 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1039 a.PostPublic = alias.PostPublic
1040 a.ListMembers = alias.ListMembers
1041 a.AllowMsgFrom = alias.AllowMsgFrom
1042 d.Aliases = maps.Clone(d.Aliases)
1043 d.Aliases[addr.Localpart.String()] = a
1048func AliasRemove(ctx context.Context, addr smtp.Address) error {
1049 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1050 _, ok := d.Aliases[addr.Localpart.String()]
1052 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1054 d.Aliases = maps.Clone(d.Aliases)
1055 delete(d.Aliases, addr.Localpart.String())
1060func AliasAddressesAdd(ctx context.Context, addr smtp.Address, addresses []string) error {
1061 if len(addresses) == 0 {
1062 return fmt.Errorf("%w: at least one address required", ErrRequest)
1064 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1065 alias, ok := d.Aliases[addr.Localpart.String()]
1067 return fmt.Errorf("%w: no such alias", ErrRequest)
1069 alias.Addresses = append(slices.Clone(alias.Addresses), addresses...)
1070 alias.ParsedAddresses = nil
1071 d.Aliases = maps.Clone(d.Aliases)
1072 d.Aliases[addr.Localpart.String()] = alias
1077func AliasAddressesRemove(ctx context.Context, addr smtp.Address, addresses []string) error {
1078 if len(addresses) == 0 {
1079 return fmt.Errorf("%w: need at least one address", ErrRequest)
1081 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1082 alias, ok := d.Aliases[addr.Localpart.String()]
1084 return fmt.Errorf("%w: no such alias", ErrRequest)
1086 alias.Addresses = slices.DeleteFunc(slices.Clone(alias.Addresses), func(addr string) bool {
1088 addresses = slices.DeleteFunc(addresses, func(a string) bool { return a == addr })
1089 return n > len(addresses)
1091 if len(addresses) > 0 {
1092 return fmt.Errorf("%w: address not found: %s", ErrRequest, strings.Join(addresses, ", "))
1094 alias.ParsedAddresses = nil
1095 d.Aliases = maps.Clone(d.Aliases)
1096 d.Aliases[addr.Localpart.String()] = alias
1101// AccountSave updates the configuration of an account. Function xmodify is called
1102// with a shallow copy of the current configuration of the account. It must not
1103// change referencing fields (e.g. existing slice/map/pointer), they may still be
1104// in use, and the change may be rolled back. Referencing values must be copied and
1105// replaced by the modify. The function may raise a panic for error handling.
1106func AccountSave(ctx context.Context, account string, xmodify func(acc *config.Account)) (rerr error) {
1107 log := pkglog.WithContext(ctx)
1110 log.Errorx("saving account fields", rerr, slog.String("account", account))
1114 defer mox.Conf.DynamicLockUnlock()()
1116 c := mox.Conf.Dynamic
1117 acc, ok := c.Accounts[account]
1119 return fmt.Errorf("%w: account not present", ErrRequest)
1124 // Compose new config without modifying existing data structures. If we fail, we
1127 nc.Accounts = map[string]config.Account{}
1128 maps.Copy(nc.Accounts, c.Accounts)
1129 nc.Accounts[account] = acc
1131 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
1132 return fmt.Errorf("writing domains.conf: %w", err)
1134 log.Info("account fields saved", slog.String("account", account))