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