1package admin
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 cryptorand "crypto/rand"
8 "crypto/rsa"
9 "crypto/x509"
10 "encoding/pem"
11 "errors"
12 "fmt"
13 "io/fs"
14 "log/slog"
15 "maps"
16 "os"
17 "path/filepath"
18 "slices"
19 "strings"
20 "time"
21
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"
31)
32
33var pkglog = mlog.New("admin", nil)
34
35var ErrRequest = errors.New("bad request")
36
37// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
38// with DKIM.
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)
42 if err != nil {
43 return nil, fmt.Errorf("generating key: %w", err)
44 }
45
46 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
47 if err != nil {
48 return nil, fmt.Errorf("marshal key: %w", err)
49 }
50
51 block := &pem.Block{
52 Type: "PRIVATE KEY",
53 Headers: map[string]string{
54 "Note": dkimKeyNote("ed25519", selector, domain),
55 },
56 Bytes: pkcs8,
57 }
58 b := &bytes.Buffer{}
59 if err := pem.Encode(b, block); err != nil {
60 return nil, fmt.Errorf("encoding pem: %w", err)
61 }
62 return b.Bytes(), nil
63}
64
65func dkimKeyNote(kind string, selector, domain dns.Domain) string {
66 s := kind + " dkim private key"
67 var zero dns.Domain
68 if selector != zero && domain != zero {
69 s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII)
70 }
71 s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339))
72 return s
73}
74
75// MakeDKIMRSAKey returns a PEM buffer containing an rsa key for use with
76// DKIM.
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)
82 if err != nil {
83 return nil, fmt.Errorf("generating key: %w", err)
84 }
85
86 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
87 if err != nil {
88 return nil, fmt.Errorf("marshal key: %w", err)
89 }
90
91 block := &pem.Block{
92 Type: "PRIVATE KEY",
93 Headers: map[string]string{
94 "Note": dkimKeyNote("rsa-2048", selector, domain),
95 },
96 Bytes: pkcs8,
97 }
98 b := &bytes.Buffer{}
99 if err := pem.Encode(b, block); err != nil {
100 return nil, fmt.Errorf("encoding pem: %w", err)
101 }
102 return b.Bytes(), nil
103}
104
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{
110 addr.String(): {},
111 },
112 RejectsMailbox: "Rejects",
113 JunkFilter: &config.JunkFilter{
114 Threshold: 0.95,
115 Params: junk.Params{
116 Onegrams: true,
117 MaxPower: .01,
118 TopWords: 10,
119 IgnoreWords: .1,
120 RareWords: 2,
121 },
122 },
123 NoCustomPassword: true,
124 }
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
129 return account
130}
131
132func writeFile(log mlog.Log, path string, data []byte) error {
133 os.MkdirAll(filepath.Dir(path), 0770)
134
135 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
136 if err != nil {
137 return fmt.Errorf("creating file %s: %s", path, err)
138 }
139 defer func() {
140 if f != nil {
141 err := f.Close()
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))
145 }
146 }()
147 if _, err := f.Write(data); err != nil {
148 return fmt.Errorf("writing file %s: %s", path, err)
149 }
150 if err := f.Close(); err != nil {
151 return fmt.Errorf("close file: %v", err)
152 }
153 f = nil
154 return nil
155}
156
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)
161
162 now := time.Now()
163 year := now.Format("2006")
164 timestamp := now.Format("20060102T150405")
165
166 var paths []string
167 defer func() {
168 for _, p := range paths {
169 err := os.Remove(p)
170 log.Check(err, "removing path for domain config", slog.String("path", p))
171 }
172 }()
173
174 confDKIM := config.DKIM{
175 Selectors: map[string]config.Selector{},
176 }
177
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 {
183 return err
184 }
185 paths = append(paths, p)
186 confDKIM.Selectors[name] = config.Selector{
187 // Example from RFC has 5 day between signing and expiration. ../rfc/6376:1393
188 // Expiration is not intended as antireplay defense, but it may help. ../rfc/6376:1340
189 // Messages in the wild have been observed with 2 hours and 1 year expiration.
190 Expiration: "72h",
191 PrivateKeyFile: keyPath,
192 }
193 return nil
194 }
195
196 addRSA := func(name string) error {
197 key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain)
198 if err != nil {
199 return fmt.Errorf("making dkim rsa private key: %s", err)
200 }
201 return addSelector("rsa2048", name, key)
202 }
203
204 if err := addRSA(year + "a"); err != nil {
205 return config.Domain{}, nil, err
206 }
207 if err := addRSA(year + "b"); err != nil {
208 return config.Domain{}, nil, err
209 }
210
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"}
215
216 confDomain := config.Domain{
217 ClientSettingsDomain: "mail." + domain.Name(),
218 LocalpartCatchallSeparator: "+",
219 DKIM: confDKIM,
220 DMARC: &config.DMARC{
221 Account: accountName,
222 Localpart: "dmarc-reports",
223 Mailbox: "DMARC",
224 },
225 TLSRPT: &config.TLSRPT{
226 Account: accountName,
227 Localpart: "tls-reports",
228 Mailbox: "TLSRPT",
229 },
230 }
231
232 if withMTASTS {
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},
240 }
241 }
242
243 rpaths := paths
244 paths = nil
245
246 return confDomain, rpaths, nil
247}
248
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)
252 defer func() {
253 if rerr != nil {
254 log.Errorx("adding dkim key", rerr,
255 slog.Any("domain", domain),
256 slog.Any("selector", selector))
257 }
258 }()
259
260 switch hash {
261 case "sha256", "sha1":
262 default:
263 return fmt.Errorf("%w: unknown hash algorithm %q", ErrRequest, hash)
264 }
265
266 var privKey []byte
267 var err error
268 var kind string
269 switch algorithm {
270 case "rsa":
271 privKey, err = MakeDKIMRSAKey(selector, domain)
272 kind = "rsa2048"
273 case "ed25519":
274 privKey, err = MakeDKIMEd25519Key(selector, domain)
275 kind = "ed25519"
276 default:
277 err = fmt.Errorf("unknown algorithm")
278 }
279 if err != nil {
280 return fmt.Errorf("%w: making dkim key: %v", ErrRequest, err)
281 }
282
283 // Only take lock now, we don't want to hold it while generating a key.
284 defer mox.Conf.DynamicLockUnlock()()
285
286 c := mox.Conf.Dynamic
287 d, ok := c.Domains[domain.Name()]
288 if !ok {
289 return fmt.Errorf("%w: domain does not exist", ErrRequest)
290 }
291
292 if _, ok := d.DKIM.Selectors[selector.Name()]; ok {
293 return fmt.Errorf("%w: selector already exists for domain", ErrRequest)
294 }
295
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)
302 }
303 removePath := p
304 defer func() {
305 if removePath != "" {
306 err := os.Remove(removePath)
307 log.Check(err, "removing path for dkim key", slog.String("path", removePath))
308 }
309 }()
310
311 nsel := config.Selector{
312 Hash: hash,
313 Canonicalization: config.Canonicalization{
314 HeaderRelaxed: headerRelaxed,
315 BodyRelaxed: bodyRelaxed,
316 },
317 Headers: headers,
318 DontSealHeaders: !seal,
319 Expiration: lifetime.String(),
320 PrivateKeyFile: keyPath,
321 }
322
323 // All good, time to update the config.
324 nd := d
325 nd.DKIM.Selectors = map[string]config.Selector{}
326 for name, osel := range d.DKIM.Selectors {
327 nd.DKIM.Selectors[name] = osel
328 }
329 nd.DKIM.Selectors[selector.Name()] = nsel
330 nc := c
331 nc.Domains = map[string]config.Domain{}
332 for name, dom := range c.Domains {
333 nc.Domains[name] = dom
334 }
335 nc.Domains[domain.Name()] = nd
336
337 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
338 return fmt.Errorf("writing domains.conf: %w", err)
339 }
340
341 log.Info("dkim key added", slog.Any("domain", domain), slog.Any("selector", selector))
342 removePath = "" // Prevent cleanup of key file.
343 return nil
344}
345
346// DKIMRemove removes the selector from the domain, moving the key file out of the way.
347func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
348 log := pkglog.WithContext(ctx)
349 defer func() {
350 if rerr != nil {
351 log.Errorx("removing dkim key", rerr,
352 slog.Any("domain", domain),
353 slog.Any("selector", selector))
354 }
355 }()
356
357 defer mox.Conf.DynamicLockUnlock()()
358
359 c := mox.Conf.Dynamic
360 d, ok := c.Domains[domain.Name()]
361 if !ok {
362 return fmt.Errorf("%w: domain does not exist", ErrRequest)
363 }
364
365 sel, ok := d.DKIM.Selectors[selector.Name()]
366 if !ok {
367 return fmt.Errorf("%w: selector does not exist for domain", ErrRequest)
368 }
369
370 nsels := map[string]config.Selector{}
371 for name, sel := range d.DKIM.Selectors {
372 if name != selector.Name() {
373 nsels[name] = sel
374 }
375 }
376 nsign := make([]string, 0, len(d.DKIM.Sign))
377 for _, name := range d.DKIM.Sign {
378 if name != selector.Name() {
379 nsign = append(nsign, name)
380 }
381 }
382
383 nd := d
384 nd.DKIM = config.DKIM{Selectors: nsels, Sign: nsign}
385 nc := c
386 nc.Domains = map[string]config.Domain{}
387 for name, dom := range c.Domains {
388 nc.Domains[name] = dom
389 }
390 nc.Domains[domain.Name()] = nd
391
392 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
393 return fmt.Errorf("writing domains.conf: %w", err)
394 }
395
396 // Move away a DKIM private key to a subdirectory "old". But only if
397 // not in use by other domains.
398 usedKeyPaths := gatherUsedKeysPaths(nc)
399 moveAwayKeys(log, map[string]config.Selector{selector.Name(): sel}, usedKeyPaths)
400
401 log.Info("dkim key removed", slog.Any("domain", domain), slog.Any("selector", selector))
402 return nil
403}
404
405// DomainAdd adds the domain to the domains config, rewriting domains.conf and
406// marking it loaded.
407//
408// accountName is used for DMARC/TLS report and potentially for the postmaster address.
409// If the account does not exist, it is created with localpart. Localpart must be
410// set only if the account does not yet exist.
411func DomainAdd(ctx context.Context, disabled bool, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) {
412 log := pkglog.WithContext(ctx)
413 defer func() {
414 if rerr != nil {
415 log.Errorx("adding domain", rerr,
416 slog.Any("disabled", disabled),
417 slog.Any("domain", domain),
418 slog.String("account", accountName),
419 slog.Any("localpart", localpart))
420 }
421 }()
422
423 defer mox.Conf.DynamicLockUnlock()()
424
425 c := mox.Conf.Dynamic
426 if _, ok := c.Domains[domain.Name()]; ok {
427 return fmt.Errorf("%w: domain already present", ErrRequest)
428 }
429
430 // Compose new config without modifying existing data structures. If we fail, we
431 // leave no trace.
432 nc := c
433 nc.Domains = map[string]config.Domain{}
434 for name, d := range c.Domains {
435 nc.Domains[name] = d
436 }
437
438 // Only enable mta-sts for domain if there is a listener with mta-sts.
439 var withMTASTS bool
440 for _, l := range mox.Conf.Static.Listeners {
441 if l.MTASTSHTTPS.Enabled {
442 withMTASTS = true
443 break
444 }
445 }
446
447 confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS)
448 if err != nil {
449 return fmt.Errorf("preparing domain config: %v", err)
450 }
451 defer func() {
452 for _, f := range cleanupFiles {
453 err := os.Remove(f)
454 log.Check(err, "cleaning up file after error", slog.String("path", f))
455 }
456 }()
457 confDomain.Disabled = disabled
458
459 if _, ok := c.Accounts[accountName]; ok && localpart != "" {
460 return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest)
461 } else if !ok && localpart == "" {
462 return fmt.Errorf("%w: account does not yet exist (specify a localpart)", ErrRequest)
463 } else if accountName == "" {
464 return fmt.Errorf("%w: account name is empty", ErrRequest)
465 } else if !ok {
466 nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain))
467 } else if accountName != mox.Conf.Static.Postmaster.Account {
468 nacc := nc.Accounts[accountName]
469 nd := map[string]config.Destination{}
470 for k, v := range nacc.Destinations {
471 nd[k] = v
472 }
473 pmaddr := smtp.NewAddress("postmaster", domain)
474 nd[pmaddr.String()] = config.Destination{}
475 nacc.Destinations = nd
476 nc.Accounts[accountName] = nacc
477 }
478
479 nc.Domains[domain.Name()] = confDomain
480
481 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
482 return fmt.Errorf("writing domains.conf: %w", err)
483 }
484 log.Info("domain added", slog.Any("domain", domain), slog.Bool("disabled", disabled))
485 cleanupFiles = nil // All good, don't cleanup.
486 return nil
487}
488
489// DomainRemove removes domain from the config, rewriting domains.conf.
490//
491// No accounts are removed, also not when they still reference this domain.
492func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
493 log := pkglog.WithContext(ctx)
494 defer func() {
495 if rerr != nil {
496 log.Errorx("removing domain", rerr, slog.Any("domain", domain))
497 }
498 }()
499
500 defer mox.Conf.DynamicLockUnlock()()
501
502 c := mox.Conf.Dynamic
503 domConf, ok := c.Domains[domain.Name()]
504 if !ok {
505 return fmt.Errorf("%w: domain does not exist", ErrRequest)
506 }
507
508 // Check that the domain isn't referenced in a TLS public key.
509 tlspubkeys, err := store.TLSPublicKeyList(ctx, "")
510 if err != nil {
511 return fmt.Errorf("%w: listing tls public keys: %s", ErrRequest, err)
512 }
513 atdom := "@" + domain.Name()
514 for _, tpk := range tlspubkeys {
515 if strings.HasSuffix(tpk.LoginAddress, atdom) {
516 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)
517 }
518 }
519
520 // Compose new config without modifying existing data structures. If we fail, we
521 // leave no trace.
522 nc := c
523 nc.Domains = map[string]config.Domain{}
524 s := domain.Name()
525 for name, d := range c.Domains {
526 if name != s {
527 nc.Domains[name] = d
528 }
529 }
530
531 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
532 return fmt.Errorf("writing domains.conf: %w", err)
533 }
534
535 // Move away any DKIM private keys to a subdirectory "old". But only if
536 // they are not in use by other domains.
537 usedKeyPaths := gatherUsedKeysPaths(nc)
538 moveAwayKeys(log, domConf.DKIM.Selectors, usedKeyPaths)
539
540 log.Info("domain removed", slog.Any("domain", domain))
541 return nil
542}
543
544func gatherUsedKeysPaths(nc config.Dynamic) map[string]bool {
545 usedKeyPaths := map[string]bool{}
546 for _, dc := range nc.Domains {
547 for _, sel := range dc.DKIM.Selectors {
548 usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
549 }
550 }
551 return usedKeyPaths
552}
553
554func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths map[string]bool) {
555 for _, sel := range sels {
556 if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
557 continue
558 }
559 src := mox.ConfigDirPath(sel.PrivateKeyFile)
560 dst := mox.ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
561 _, err := os.Stat(dst)
562 if err == nil {
563 err = fmt.Errorf("destination already exists")
564 } else if os.IsNotExist(err) {
565 os.MkdirAll(filepath.Dir(dst), 0770)
566 err = os.Rename(src, dst)
567 }
568 if err != nil {
569 log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst))
570 }
571 }
572}
573
574// DomainSave calls xmodify with a shallow copy of the domain config. xmodify
575// can modify the config, but must clone all referencing data it changes.
576// xmodify may employ panic-based error handling. After xmodify returns, the
577// modified config is verified, saved and takes effect.
578func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain) error) (rerr error) {
579 log := pkglog.WithContext(ctx)
580 defer func() {
581 if rerr != nil {
582 log.Errorx("saving domain config", rerr)
583 }
584 }()
585
586 defer mox.Conf.DynamicLockUnlock()()
587
588 nc := mox.Conf.Dynamic // Shallow copy.
589 dom, ok := nc.Domains[domainName] // dom is a shallow copy.
590 if !ok {
591 return fmt.Errorf("%w: domain not present", ErrRequest)
592 }
593
594 if err := xmodify(&dom); err != nil {
595 return err
596 }
597
598 // Compose new config without modifying existing data structures. If we fail, we
599 // leave no trace.
600 nc.Domains = map[string]config.Domain{}
601 for name, d := range mox.Conf.Dynamic.Domains {
602 nc.Domains[name] = d
603 }
604 nc.Domains[domainName] = dom
605
606 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
607 return fmt.Errorf("writing domains.conf: %w", err)
608 }
609
610 log.Info("domain saved")
611 return nil
612}
613
614// ConfigSave calls xmodify with a shallow copy of the dynamic config. xmodify
615// can modify the config, but must clone all referencing data it changes.
616// xmodify may employ panic-based error handling. After xmodify returns, the
617// modified config is verified, saved and takes effect.
618func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr error) {
619 log := pkglog.WithContext(ctx)
620 defer func() {
621 if rerr != nil {
622 log.Errorx("saving config", rerr)
623 }
624 }()
625
626 defer mox.Conf.DynamicLockUnlock()()
627
628 nc := mox.Conf.Dynamic // Shallow copy.
629 xmodify(&nc)
630
631 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
632 return fmt.Errorf("writing domains.conf: %w", err)
633 }
634
635 log.Info("config saved")
636 return nil
637}
638
639// AccountAdd adds an account and an initial address and reloads the configuration.
640//
641// The new account does not have a password, so cannot yet log in. Email can be
642// delivered.
643//
644// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd.
645func AccountAdd(ctx context.Context, account, address string) (rerr error) {
646 log := pkglog.WithContext(ctx)
647 defer func() {
648 if rerr != nil {
649 log.Errorx("adding account", rerr, slog.String("account", account), slog.String("address", address))
650 }
651 }()
652
653 addr, err := smtp.ParseAddress(address)
654 if err != nil {
655 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
656 }
657
658 defer mox.Conf.DynamicLockUnlock()()
659
660 c := mox.Conf.Dynamic
661 if _, ok := c.Accounts[account]; ok {
662 return fmt.Errorf("%w: account already present", ErrRequest)
663 }
664
665 // Ensure the directory does not exist, e.g. due to pending account removal, or an
666 // otherwise failed cleanup.
667 accountDir := filepath.Join(mox.DataDirPath("accounts"), account)
668 if _, err := os.Stat(accountDir); err == nil {
669 return fmt.Errorf("%w: account directory %q already/still exists", ErrRequest, accountDir)
670 } else if !errors.Is(err, fs.ErrNotExist) {
671 return fmt.Errorf(`%w: stat account directory %q, expected "does not exist": %v`, ErrRequest, accountDir, err)
672 }
673
674 if err := checkAddressAvailable(addr); err != nil {
675 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
676 }
677
678 // Compose new config without modifying existing data structures. If we fail, we
679 // leave no trace.
680 nc := c
681 nc.Accounts = map[string]config.Account{}
682 for name, a := range c.Accounts {
683 nc.Accounts[name] = a
684 }
685 nc.Accounts[account] = MakeAccountConfig(addr)
686
687 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
688 return fmt.Errorf("writing domains.conf: %w", err)
689 }
690 log.Info("account added", slog.String("account", account), slog.Any("address", addr))
691 return nil
692}
693
694// AccountRemove removes an account and reloads the configuration.
695func AccountRemove(ctx context.Context, account string) (rerr error) {
696 log := pkglog.WithContext(ctx)
697 defer func() {
698 if rerr != nil {
699 log.Errorx("adding account", rerr, slog.String("account", account))
700 }
701 }()
702
703 // Open account now. The deferred Close MUST happen after the dynamic unlock,
704 // because during tests the consistency checker takes the same lock.
705 acc, err := store.OpenAccount(log, account, false)
706 if err != nil {
707 return fmt.Errorf("%w: open account: %v", ErrRequest, err)
708 }
709 defer func() {
710 err := acc.SessionsClear(context.Background(), log)
711 log.Check(err, "clearing account login sessions")
712
713 err = acc.Close()
714 log.Check(err, "closing account after error")
715 }()
716
717 // Fail message and webhook deliveries from the queue for this account.
718 // Must be before the dynamic lock, since failing a message delivers a DSN to the
719 // account. We fail instead of drop because an error can still occur causing us to
720 // abort.
721 nfailed, err := queue.Fail(ctx, log, queue.Filter{Account: account})
722 if nfailed > 0 {
723 log.Info("failing queued messages for removed account", slog.Int("count", nfailed))
724 }
725 if err != nil {
726 return fmt.Errorf("failing queued messages for account before removing: %v", err)
727 }
728 ncanceled, err := queue.HookCancel(ctx, log, queue.HookFilter{Account: account})
729 if ncanceled > 0 {
730 log.Info("canceling queued webhooks for removed account", slog.Int("count", ncanceled))
731 }
732 if err != nil {
733 return fmt.Errorf("canceling queued webhooks for account before removing: %v", err)
734 }
735
736 // Cleanup suppressed addresses for account.
737 suppressions, err := queue.SuppressionList(ctx, account)
738 if err != nil {
739 return fmt.Errorf("listing suppressed addresses for account: %v", err)
740 }
741 for _, sup := range suppressions {
742 addr, err := smtp.ParseAddress(sup.BaseAddress)
743 if err != nil {
744 return fmt.Errorf("parsing suppressed address %q: %v", sup.BaseAddress, err)
745 }
746 if err := queue.SuppressionRemove(ctx, account, addr.Path()); err != nil {
747 return fmt.Errorf("removing suppression %q for account: %v", sup.BaseAddress, err)
748 }
749 }
750
751 defer mox.Conf.DynamicLockUnlock()()
752
753 c := mox.Conf.Dynamic
754
755 // Compose new config without modifying existing data structures. If we fail, we
756 // leave no trace.
757 nc := c
758 nc.Accounts = map[string]config.Account{}
759 for name, a := range c.Accounts {
760 if name != account {
761 nc.Accounts[name] = a
762 }
763 }
764
765 // Write new config file.
766 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
767 return fmt.Errorf("writing domains.conf: %w", err)
768 }
769
770 // Mark files for account for removal as soon as all references have gone.
771 if err := acc.Remove(context.Background()); err != nil {
772 return fmt.Errorf("account removed from configuration file, but scheduling account directory for removal failed: %v", err)
773 }
774
775 log.Info("account marked for removal", slog.String("account", account))
776 return nil
777}
778
779// checkAddressAvailable checks that the address after canonicalization is not
780// already configured, and that its localpart does not contain a catchall
781// localpart separator.
782//
783// Must be called with config lock held.
784func checkAddressAvailable(addr smtp.Address) error {
785 dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()]
786 if !ok {
787 return fmt.Errorf("domain does not exist")
788 }
789 lp := mox.CanonicalLocalpart(addr.Localpart, dc)
790 if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok {
791 return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
792 }
793 for _, sep := range dc.LocalpartCatchallSeparatorsEffective {
794 if strings.Contains(string(addr.Localpart), sep) {
795 return fmt.Errorf("localpart cannot include domain catchall separator %s", sep)
796 }
797 }
798 if _, ok := dc.Aliases[lp.String()]; ok {
799 return fmt.Errorf("address in use as alias")
800 }
801 return nil
802}
803
804// AddressAdd adds an email address to an account and reloads the configuration. If
805// address starts with an @ it is treated as a catchall address for the domain.
806func AddressAdd(ctx context.Context, address, account string) (rerr error) {
807 log := pkglog.WithContext(ctx)
808 defer func() {
809 if rerr != nil {
810 log.Errorx("adding address", rerr, slog.String("address", address), slog.String("account", account))
811 }
812 }()
813
814 defer mox.Conf.DynamicLockUnlock()()
815
816 c := mox.Conf.Dynamic
817 a, ok := c.Accounts[account]
818 if !ok {
819 return fmt.Errorf("%w: account does not exist", ErrRequest)
820 }
821
822 var destAddr string
823 if strings.HasPrefix(address, "@") {
824 d, err := dns.ParseDomain(address[1:])
825 if err != nil {
826 return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err)
827 }
828 dname := d.Name()
829 destAddr = "@" + dname
830 if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok {
831 return fmt.Errorf("%w: domain does not exist", ErrRequest)
832 } else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok {
833 return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
834 }
835 } else {
836 addr, err := smtp.ParseAddress(address)
837 if err != nil {
838 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
839 }
840
841 if err := checkAddressAvailable(addr); err != nil {
842 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
843 }
844 destAddr = addr.String()
845 }
846
847 // Compose new config without modifying existing data structures. If we fail, we
848 // leave no trace.
849 nc := c
850 nc.Accounts = map[string]config.Account{}
851 for name, a := range c.Accounts {
852 nc.Accounts[name] = a
853 }
854 nd := map[string]config.Destination{}
855 for name, d := range a.Destinations {
856 nd[name] = d
857 }
858 nd[destAddr] = config.Destination{}
859 a.Destinations = nd
860 nc.Accounts[account] = a
861
862 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
863 return fmt.Errorf("writing domains.conf: %w", err)
864 }
865 log.Info("address added", slog.String("address", address), slog.String("account", account))
866 return nil
867}
868
869// AddressRemove removes an email address and reloads the configuration.
870// Address can be a catchall address for the domain of the form "@<domain>".
871//
872// If the address is member of an alias, remove it from from the alias, unless it
873// is the last member.
874func AddressRemove(ctx context.Context, address string) (rerr error) {
875 log := pkglog.WithContext(ctx)
876 defer func() {
877 if rerr != nil {
878 log.Errorx("removing address", rerr, slog.String("address", address))
879 }
880 }()
881
882 defer mox.Conf.DynamicLockUnlock()()
883
884 ad, ok := mox.Conf.AccountDestinationsLocked[address]
885 if !ok {
886 return fmt.Errorf("%w: address does not exists", ErrRequest)
887 }
888
889 // Compose new config without modifying existing data structures. If we fail, we
890 // leave no trace.
891 a, ok := mox.Conf.Dynamic.Accounts[ad.Account]
892 if !ok {
893 return fmt.Errorf("internal error: cannot find account")
894 }
895 na := a
896 na.Destinations = map[string]config.Destination{}
897 var dropped bool
898 for destAddr, d := range a.Destinations {
899 if destAddr != address {
900 na.Destinations[destAddr] = d
901 } else {
902 dropped = true
903 }
904 }
905 if !dropped {
906 return fmt.Errorf("%w: address not removed, likely a postmaster/reporting address", ErrRequest)
907 }
908
909 // Also remove matching address from FromIDLoginAddresses, composing a new slice.
910 // Refuse if address is referenced in a TLS public key.
911 var dom dns.Domain
912 var pa smtp.Address // For non-catchall addresses (most).
913 var err error
914 if strings.HasPrefix(address, "@") {
915 dom, err = dns.ParseDomain(address[1:])
916 if err != nil {
917 return fmt.Errorf("%w: parsing domain for catchall address: %v", ErrRequest, err)
918 }
919 } else {
920 pa, err = smtp.ParseAddress(address)
921 if err != nil {
922 return fmt.Errorf("%w: parsing address: %v", ErrRequest, err)
923 }
924 dom = pa.Domain
925 }
926 dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
927 if !ok {
928 return fmt.Errorf("%w: unknown domain in address %q", ErrRequest, address)
929 }
930
931 var fromIDLoginAddresses []string
932 for i, fa := range a.ParsedFromIDLoginAddresses {
933 if fa.Domain != dom {
934 // Keep for different domain.
935 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
936 continue
937 }
938 if strings.HasPrefix(address, "@") {
939 continue
940 }
941 flp := mox.CanonicalLocalpart(fa.Localpart, dc)
942 alp := mox.CanonicalLocalpart(pa.Localpart, dc)
943 if alp != flp {
944 // Keep for different localpart.
945 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
946 }
947 }
948 na.FromIDLoginAddresses = fromIDLoginAddresses
949
950 // Refuse if there is still a TLS public key that references this address.
951 tlspubkeys, err := store.TLSPublicKeyList(ctx, ad.Account)
952 if err != nil {
953 return fmt.Errorf("%w: listing tls public keys for account: %v", ErrRequest, err)
954 }
955 for _, tpk := range tlspubkeys {
956 a, err := smtp.ParseAddress(tpk.LoginAddress)
957 if err != nil {
958 return fmt.Errorf("%w: parsing address from tls public key: %v", ErrRequest, err)
959 }
960 lp := mox.CanonicalLocalpart(a.Localpart, dc)
961 ca := smtp.NewAddress(lp, a.Domain)
962 if xad, ok := mox.Conf.AccountDestinationsLocked[ca.String()]; ok && xad.Localpart == ad.Localpart {
963 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)
964 }
965 }
966
967 // And remove as member from aliases configured in domains.
968 domains := maps.Clone(mox.Conf.Dynamic.Domains)
969 for _, aa := range na.Aliases {
970 if aa.SubscriptionAddress != address {
971 continue
972 }
973
974 aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
975
976 dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
977 if !ok {
978 return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
979 }
980 a, ok := dom.Aliases[aa.Alias.LocalpartStr]
981 if !ok {
982 return fmt.Errorf("cannot find alias %s", aliasAddr)
983 }
984 a.Addresses = slices.Clone(a.Addresses)
985 a.Addresses = slices.DeleteFunc(a.Addresses, func(v string) bool { return v == address })
986 if len(a.Addresses) == 0 {
987 return fmt.Errorf("address is last member of alias %s, add new members or remove alias first", aliasAddr)
988 }
989 a.ParsedAddresses = nil // Filled when parsing config.
990 dom.Aliases = maps.Clone(dom.Aliases)
991 dom.Aliases[aa.Alias.LocalpartStr] = a
992 domains[aa.Alias.Domain.Name()] = dom
993 }
994 na.Aliases = nil // Filled when parsing config.
995
996 // Check that no message in the queue is for this address. The new account config
997 // must still match this address.
998 msgs, err := queue.List(ctx, queue.Filter{Account: ad.Account}, queue.Sort{})
999 if err != nil {
1000 return fmt.Errorf("listing messages in queue for account: %v", err)
1001 }
1002 for _, m := range msgs {
1003 dc, ok := mox.Conf.Dynamic.Domains[m.SenderDomainStr]
1004 if !ok {
1005 return fmt.Errorf("%w: unknown sender domain %q in queued message", ErrRequest, m.SenderDomainStr)
1006 }
1007 lp := mox.CanonicalLocalpart(m.SenderLocalpart, dc)
1008 sa := smtp.NewAddress(lp, m.SenderDomain.Domain).String()
1009 if strings.HasPrefix(address, "@") {
1010 // We are removing the catchall address. The queued message sender address must be
1011 // configured explicitly to still belong to the account.
1012 if xad, ok := mox.Conf.AccountDestinationsLocked[sa]; !ok || xad.Account != ad.Account {
1013 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)
1014 }
1015 } else {
1016 // We are removing a regular address. If the queued message matches the address,
1017 // the catchall address must be configured for this account.
1018 if xad, ok := mox.Conf.AccountDestinationsLocked["@"+m.SenderDomainStr]; (!ok || xad.Account != ad.Account) && sa == address {
1019 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)
1020 }
1021 }
1022 }
1023
1024 nc := mox.Conf.Dynamic
1025 nc.Accounts = map[string]config.Account{}
1026 for name, a := range mox.Conf.Dynamic.Accounts {
1027 nc.Accounts[name] = a
1028 }
1029 nc.Accounts[ad.Account] = na
1030 nc.Domains = domains
1031
1032 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
1033 return fmt.Errorf("writing domains.conf: %w", err)
1034 }
1035 log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
1036 return nil
1037}
1038
1039func AliasAdd(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1040 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1041 if _, ok := d.Aliases[addr.Localpart.String()]; ok {
1042 return fmt.Errorf("%w: alias already present", ErrRequest)
1043 }
1044 if d.Aliases == nil {
1045 d.Aliases = map[string]config.Alias{}
1046 }
1047 d.Aliases = maps.Clone(d.Aliases)
1048 d.Aliases[addr.Localpart.String()] = alias
1049 return nil
1050 })
1051}
1052
1053func AliasUpdate(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1054 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1055 a, ok := d.Aliases[addr.Localpart.String()]
1056 if !ok {
1057 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1058 }
1059 a.PostPublic = alias.PostPublic
1060 a.ListMembers = alias.ListMembers
1061 a.AllowMsgFrom = alias.AllowMsgFrom
1062 d.Aliases = maps.Clone(d.Aliases)
1063 d.Aliases[addr.Localpart.String()] = a
1064 return nil
1065 })
1066}
1067
1068func AliasRemove(ctx context.Context, addr smtp.Address) error {
1069 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1070 _, ok := d.Aliases[addr.Localpart.String()]
1071 if !ok {
1072 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1073 }
1074 d.Aliases = maps.Clone(d.Aliases)
1075 delete(d.Aliases, addr.Localpart.String())
1076 return nil
1077 })
1078}
1079
1080func AliasAddressesAdd(ctx context.Context, addr smtp.Address, addresses []string) error {
1081 if len(addresses) == 0 {
1082 return fmt.Errorf("%w: at least one address required", ErrRequest)
1083 }
1084 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1085 alias, ok := d.Aliases[addr.Localpart.String()]
1086 if !ok {
1087 return fmt.Errorf("%w: no such alias", ErrRequest)
1088 }
1089 alias.Addresses = append(slices.Clone(alias.Addresses), addresses...)
1090 alias.ParsedAddresses = nil
1091 d.Aliases = maps.Clone(d.Aliases)
1092 d.Aliases[addr.Localpart.String()] = alias
1093 return nil
1094 })
1095}
1096
1097func AliasAddressesRemove(ctx context.Context, addr smtp.Address, addresses []string) error {
1098 if len(addresses) == 0 {
1099 return fmt.Errorf("%w: need at least one address", ErrRequest)
1100 }
1101 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1102 alias, ok := d.Aliases[addr.Localpart.String()]
1103 if !ok {
1104 return fmt.Errorf("%w: no such alias", ErrRequest)
1105 }
1106 alias.Addresses = slices.DeleteFunc(slices.Clone(alias.Addresses), func(addr string) bool {
1107 n := len(addresses)
1108 addresses = slices.DeleteFunc(addresses, func(a string) bool { return a == addr })
1109 return n > len(addresses)
1110 })
1111 if len(addresses) > 0 {
1112 return fmt.Errorf("%w: address not found: %s", ErrRequest, strings.Join(addresses, ", "))
1113 }
1114 alias.ParsedAddresses = nil
1115 d.Aliases = maps.Clone(d.Aliases)
1116 d.Aliases[addr.Localpart.String()] = alias
1117 return nil
1118 })
1119}
1120
1121// AccountSave updates the configuration of an account. Function xmodify is called
1122// with a shallow copy of the current configuration of the account. It must not
1123// change referencing fields (e.g. existing slice/map/pointer), they may still be
1124// in use, and the change may be rolled back. Referencing values must be copied and
1125// replaced by the modify. The function may raise a panic for error handling.
1126func AccountSave(ctx context.Context, account string, xmodify func(acc *config.Account)) (rerr error) {
1127 log := pkglog.WithContext(ctx)
1128 defer func() {
1129 if rerr != nil {
1130 log.Errorx("saving account fields", rerr, slog.String("account", account))
1131 }
1132 }()
1133
1134 defer mox.Conf.DynamicLockUnlock()()
1135
1136 c := mox.Conf.Dynamic
1137 acc, ok := c.Accounts[account]
1138 if !ok {
1139 return fmt.Errorf("%w: account not present", ErrRequest)
1140 }
1141
1142 xmodify(&acc)
1143
1144 // Compose new config without modifying existing data structures. If we fail, we
1145 // leave no trace.
1146 nc := c
1147 nc.Accounts = map[string]config.Account{}
1148 for name, a := range c.Accounts {
1149 nc.Accounts[name] = a
1150 }
1151 nc.Accounts[account] = acc
1152
1153 if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
1154 return fmt.Errorf("writing domains.conf: %w", err)
1155 }
1156 log.Info("account fields saved", slog.String("account", account))
1157 return nil
1158}
1159