1package mox
2
3import (
4 "bytes"
5 "context"
6 "crypto"
7 "crypto/ed25519"
8 cryptorand "crypto/rand"
9 "crypto/rsa"
10 "crypto/sha256"
11 "crypto/x509"
12 "encoding/pem"
13 "errors"
14 "fmt"
15 "log/slog"
16 "net"
17 "net/url"
18 "os"
19 "path/filepath"
20 "slices"
21 "sort"
22 "strings"
23 "time"
24
25 "golang.org/x/exp/maps"
26
27 "github.com/mjl-/adns"
28
29 "github.com/mjl-/mox/config"
30 "github.com/mjl-/mox/dkim"
31 "github.com/mjl-/mox/dmarc"
32 "github.com/mjl-/mox/dns"
33 "github.com/mjl-/mox/junk"
34 "github.com/mjl-/mox/mlog"
35 "github.com/mjl-/mox/mtasts"
36 "github.com/mjl-/mox/smtp"
37 "github.com/mjl-/mox/tlsrpt"
38)
39
40var ErrRequest = errors.New("bad request")
41
42// TXTStrings returns a TXT record value as one or more quoted strings, each max
43// 100 characters. In case of multiple strings, a multi-line record is returned.
44func TXTStrings(s string) string {
45 if len(s) <= 100 {
46 return `"` + s + `"`
47 }
48
49 r := "(\n"
50 for len(s) > 0 {
51 n := len(s)
52 if n > 100 {
53 n = 100
54 }
55 if r != "" {
56 r += " "
57 }
58 r += "\t\t\"" + s[:n] + "\"\n"
59 s = s[n:]
60 }
61 r += "\t)"
62 return r
63}
64
65// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
66// with DKIM.
67// selector and domain can be empty. If not, they are used in the note.
68func MakeDKIMEd25519Key(selector, domain dns.Domain) ([]byte, error) {
69 _, privKey, err := ed25519.GenerateKey(cryptorand.Reader)
70 if err != nil {
71 return nil, fmt.Errorf("generating key: %w", err)
72 }
73
74 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
75 if err != nil {
76 return nil, fmt.Errorf("marshal key: %w", err)
77 }
78
79 block := &pem.Block{
80 Type: "PRIVATE KEY",
81 Headers: map[string]string{
82 "Note": dkimKeyNote("ed25519", selector, domain),
83 },
84 Bytes: pkcs8,
85 }
86 b := &bytes.Buffer{}
87 if err := pem.Encode(b, block); err != nil {
88 return nil, fmt.Errorf("encoding pem: %w", err)
89 }
90 return b.Bytes(), nil
91}
92
93func dkimKeyNote(kind string, selector, domain dns.Domain) string {
94 s := kind + " dkim private key"
95 var zero dns.Domain
96 if selector != zero && domain != zero {
97 s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII)
98 }
99 s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339))
100 return s
101}
102
103// MakeDKIMRSAKey returns a PEM buffer containing an rsa key for use with
104// DKIM.
105// selector and domain can be empty. If not, they are used in the note.
106func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
107 // 2048 bits seems reasonable in 2022, 1024 is on the low side, larger
108 // keys may not fit in UDP DNS response.
109 privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
110 if err != nil {
111 return nil, fmt.Errorf("generating key: %w", err)
112 }
113
114 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
115 if err != nil {
116 return nil, fmt.Errorf("marshal key: %w", err)
117 }
118
119 block := &pem.Block{
120 Type: "PRIVATE KEY",
121 Headers: map[string]string{
122 "Note": dkimKeyNote("rsa-2048", selector, domain),
123 },
124 Bytes: pkcs8,
125 }
126 b := &bytes.Buffer{}
127 if err := pem.Encode(b, block); err != nil {
128 return nil, fmt.Errorf("encoding pem: %w", err)
129 }
130 return b.Bytes(), nil
131}
132
133// MakeAccountConfig returns a new account configuration for an email address.
134func MakeAccountConfig(addr smtp.Address) config.Account {
135 account := config.Account{
136 Domain: addr.Domain.Name(),
137 Destinations: map[string]config.Destination{
138 addr.String(): {},
139 },
140 RejectsMailbox: "Rejects",
141 JunkFilter: &config.JunkFilter{
142 Threshold: 0.95,
143 Params: junk.Params{
144 Onegrams: true,
145 MaxPower: .01,
146 TopWords: 10,
147 IgnoreWords: .1,
148 RareWords: 2,
149 },
150 },
151 }
152 account.AutomaticJunkFlags.Enabled = true
153 account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
154 account.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
155 account.SubjectPass.Period = 12 * time.Hour
156 return account
157}
158
159func writeFile(log mlog.Log, path string, data []byte) error {
160 os.MkdirAll(filepath.Dir(path), 0770)
161
162 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
163 if err != nil {
164 return fmt.Errorf("creating file %s: %s", path, err)
165 }
166 defer func() {
167 if f != nil {
168 err := f.Close()
169 log.Check(err, "closing file after error")
170 err = os.Remove(path)
171 log.Check(err, "removing file after error", slog.String("path", path))
172 }
173 }()
174 if _, err := f.Write(data); err != nil {
175 return fmt.Errorf("writing file %s: %s", path, err)
176 }
177 if err := f.Close(); err != nil {
178 return fmt.Errorf("close file: %v", err)
179 }
180 f = nil
181 return nil
182}
183
184// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
185// accountName for DMARC and TLS reports.
186func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
187 log := pkglog.WithContext(ctx)
188
189 now := time.Now()
190 year := now.Format("2006")
191 timestamp := now.Format("20060102T150405")
192
193 var paths []string
194 defer func() {
195 for _, p := range paths {
196 err := os.Remove(p)
197 log.Check(err, "removing path for domain config", slog.String("path", p))
198 }
199 }()
200
201 confDKIM := config.DKIM{
202 Selectors: map[string]config.Selector{},
203 }
204
205 addSelector := func(kind, name string, privKey []byte) error {
206 record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
207 keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
208 p := configDirPath(ConfigDynamicPath, keyPath)
209 if err := writeFile(log, p, privKey); err != nil {
210 return err
211 }
212 paths = append(paths, p)
213 confDKIM.Selectors[name] = config.Selector{
214 // Example from RFC has 5 day between signing and expiration. ../rfc/6376:1393
215 // Expiration is not intended as antireplay defense, but it may help. ../rfc/6376:1340
216 // Messages in the wild have been observed with 2 hours and 1 year expiration.
217 Expiration: "72h",
218 PrivateKeyFile: keyPath,
219 }
220 return nil
221 }
222
223 addEd25519 := func(name string) error {
224 key, err := MakeDKIMEd25519Key(dns.Domain{ASCII: name}, domain)
225 if err != nil {
226 return fmt.Errorf("making dkim ed25519 private key: %s", err)
227 }
228 return addSelector("ed25519", name, key)
229 }
230
231 addRSA := func(name string) error {
232 key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain)
233 if err != nil {
234 return fmt.Errorf("making dkim rsa private key: %s", err)
235 }
236 return addSelector("rsa2048", name, key)
237 }
238
239 if err := addEd25519(year + "a"); err != nil {
240 return config.Domain{}, nil, err
241 }
242 if err := addRSA(year + "b"); err != nil {
243 return config.Domain{}, nil, err
244 }
245 if err := addEd25519(year + "c"); err != nil {
246 return config.Domain{}, nil, err
247 }
248 if err := addRSA(year + "d"); err != nil {
249 return config.Domain{}, nil, err
250 }
251
252 // We sign with the first two. In case they are misused, the switch to the other
253 // keys is easy, just change the config. Operators should make the public key field
254 // of the misused keys empty in the DNS records to disable the misused keys.
255 confDKIM.Sign = []string{year + "a", year + "b"}
256
257 confDomain := config.Domain{
258 ClientSettingsDomain: "mail." + domain.Name(),
259 LocalpartCatchallSeparator: "+",
260 DKIM: confDKIM,
261 DMARC: &config.DMARC{
262 Account: accountName,
263 Localpart: "dmarc-reports",
264 Mailbox: "DMARC",
265 },
266 TLSRPT: &config.TLSRPT{
267 Account: accountName,
268 Localpart: "tls-reports",
269 Mailbox: "TLSRPT",
270 },
271 }
272
273 if withMTASTS {
274 confDomain.MTASTS = &config.MTASTS{
275 PolicyID: time.Now().UTC().Format("20060102T150405"),
276 Mode: mtasts.ModeEnforce,
277 // We start out with 24 hour, and warn in the admin interface that users should
278 // increase it to weeks once the setup works.
279 MaxAge: 24 * time.Hour,
280 MX: []string{hostname.ASCII},
281 }
282 }
283
284 rpaths := paths
285 paths = nil
286
287 return confDomain, rpaths, nil
288}
289
290// DKIMAdd adds a DKIM selector for a domain, generating a key and writing it to disk.
291func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) (rerr error) {
292 log := pkglog.WithContext(ctx)
293 defer func() {
294 if rerr != nil {
295 log.Errorx("adding dkim key", rerr,
296 slog.Any("domain", domain),
297 slog.Any("selector", selector))
298 }
299 }()
300
301 switch hash {
302 case "sha256", "sha1":
303 default:
304 return fmt.Errorf("%w: unknown hash algorithm %q", ErrRequest, hash)
305 }
306
307 var privKey []byte
308 var err error
309 var kind string
310 switch algorithm {
311 case "rsa":
312 privKey, err = MakeDKIMRSAKey(selector, domain)
313 kind = "rsa2048"
314 case "ed25519":
315 privKey, err = MakeDKIMEd25519Key(selector, domain)
316 kind = "ed25519"
317 default:
318 err = fmt.Errorf("unknown algorithm")
319 }
320 if err != nil {
321 return fmt.Errorf("%w: making dkim key: %v", ErrRequest, err)
322 }
323
324 // Only take lock now, we don't want to hold it while generating a key.
325 Conf.dynamicMutex.Lock()
326 defer Conf.dynamicMutex.Unlock()
327
328 c := Conf.Dynamic
329 d, ok := c.Domains[domain.Name()]
330 if !ok {
331 return fmt.Errorf("%w: domain does not exist", ErrRequest)
332 }
333
334 if _, ok := d.DKIM.Selectors[selector.Name()]; ok {
335 return fmt.Errorf("%w: selector already exists for domain", ErrRequest)
336 }
337
338 record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
339 timestamp := time.Now().Format("20060102T150405")
340 keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
341 p := configDirPath(ConfigDynamicPath, keyPath)
342 if err := writeFile(log, p, privKey); err != nil {
343 return fmt.Errorf("writing key file: %v", err)
344 }
345 removePath := p
346 defer func() {
347 if removePath != "" {
348 err := os.Remove(removePath)
349 log.Check(err, "removing path for dkim key", slog.String("path", removePath))
350 }
351 }()
352
353 nsel := config.Selector{
354 Hash: hash,
355 Canonicalization: config.Canonicalization{
356 HeaderRelaxed: headerRelaxed,
357 BodyRelaxed: bodyRelaxed,
358 },
359 Headers: headers,
360 DontSealHeaders: !seal,
361 Expiration: lifetime.String(),
362 PrivateKeyFile: keyPath,
363 }
364
365 // All good, time to update the config.
366 nd := d
367 nd.DKIM.Selectors = map[string]config.Selector{}
368 for name, osel := range d.DKIM.Selectors {
369 nd.DKIM.Selectors[name] = osel
370 }
371 nd.DKIM.Selectors[selector.Name()] = nsel
372 nc := c
373 nc.Domains = map[string]config.Domain{}
374 for name, dom := range c.Domains {
375 nc.Domains[name] = dom
376 }
377 nc.Domains[domain.Name()] = nd
378
379 if err := writeDynamic(ctx, log, nc); err != nil {
380 return fmt.Errorf("writing domains.conf: %w", err)
381 }
382
383 log.Info("dkim key added", slog.Any("domain", domain), slog.Any("selector", selector))
384 removePath = "" // Prevent cleanup of key file.
385 return nil
386}
387
388// DKIMRemove removes the selector from the domain, moving the key file out of the way.
389func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
390 log := pkglog.WithContext(ctx)
391 defer func() {
392 if rerr != nil {
393 log.Errorx("removing dkim key", rerr,
394 slog.Any("domain", domain),
395 slog.Any("selector", selector))
396 }
397 }()
398
399 Conf.dynamicMutex.Lock()
400 defer Conf.dynamicMutex.Unlock()
401
402 c := Conf.Dynamic
403 d, ok := c.Domains[domain.Name()]
404 if !ok {
405 return fmt.Errorf("%w: domain does not exist", ErrRequest)
406 }
407
408 sel, ok := d.DKIM.Selectors[selector.Name()]
409 if !ok {
410 return fmt.Errorf("%w: selector does not exist for domain", ErrRequest)
411 }
412
413 nsels := map[string]config.Selector{}
414 for name, sel := range d.DKIM.Selectors {
415 if name != selector.Name() {
416 nsels[name] = sel
417 }
418 }
419 nsign := make([]string, 0, len(d.DKIM.Sign))
420 for _, name := range d.DKIM.Sign {
421 if name != selector.Name() {
422 nsign = append(nsign, name)
423 }
424 }
425
426 nd := d
427 nd.DKIM = config.DKIM{Selectors: nsels, Sign: nsign}
428 nc := c
429 nc.Domains = map[string]config.Domain{}
430 for name, dom := range c.Domains {
431 nc.Domains[name] = dom
432 }
433 nc.Domains[domain.Name()] = nd
434
435 if err := writeDynamic(ctx, log, nc); err != nil {
436 return fmt.Errorf("writing domains.conf: %w", err)
437 }
438
439 // Move away a DKIM private key to a subdirectory "old". But only if
440 // not in use by other domains.
441 usedKeyPaths := gatherUsedKeysPaths(nc)
442 moveAwayKeys(log, map[string]config.Selector{selector.Name(): sel}, usedKeyPaths)
443
444 log.Info("dkim key removed", slog.Any("domain", domain), slog.Any("selector", selector))
445 return nil
446}
447
448// DomainAdd adds the domain to the domains config, rewriting domains.conf and
449// marking it loaded.
450//
451// accountName is used for DMARC/TLS report and potentially for the postmaster address.
452// If the account does not exist, it is created with localpart. Localpart must be
453// set only if the account does not yet exist.
454func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) {
455 log := pkglog.WithContext(ctx)
456 defer func() {
457 if rerr != nil {
458 log.Errorx("adding domain", rerr,
459 slog.Any("domain", domain),
460 slog.String("account", accountName),
461 slog.Any("localpart", localpart))
462 }
463 }()
464
465 Conf.dynamicMutex.Lock()
466 defer Conf.dynamicMutex.Unlock()
467
468 c := Conf.Dynamic
469 if _, ok := c.Domains[domain.Name()]; ok {
470 return fmt.Errorf("%w: domain already present", ErrRequest)
471 }
472
473 // Compose new config without modifying existing data structures. If we fail, we
474 // leave no trace.
475 nc := c
476 nc.Domains = map[string]config.Domain{}
477 for name, d := range c.Domains {
478 nc.Domains[name] = d
479 }
480
481 // Only enable mta-sts for domain if there is a listener with mta-sts.
482 var withMTASTS bool
483 for _, l := range Conf.Static.Listeners {
484 if l.MTASTSHTTPS.Enabled {
485 withMTASTS = true
486 break
487 }
488 }
489
490 confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
491 if err != nil {
492 return fmt.Errorf("preparing domain config: %v", err)
493 }
494 defer func() {
495 for _, f := range cleanupFiles {
496 err := os.Remove(f)
497 log.Check(err, "cleaning up file after error", slog.String("path", f))
498 }
499 }()
500
501 if _, ok := c.Accounts[accountName]; ok && localpart != "" {
502 return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest)
503 } else if !ok && localpart == "" {
504 return fmt.Errorf("%w: account does not yet exist (specify a localpart)", ErrRequest)
505 } else if accountName == "" {
506 return fmt.Errorf("%w: account name is empty", ErrRequest)
507 } else if !ok {
508 nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain})
509 } else if accountName != Conf.Static.Postmaster.Account {
510 nacc := nc.Accounts[accountName]
511 nd := map[string]config.Destination{}
512 for k, v := range nacc.Destinations {
513 nd[k] = v
514 }
515 pmaddr := smtp.Address{Localpart: "postmaster", Domain: domain}
516 nd[pmaddr.String()] = config.Destination{}
517 nacc.Destinations = nd
518 nc.Accounts[accountName] = nacc
519 }
520
521 nc.Domains[domain.Name()] = confDomain
522
523 if err := writeDynamic(ctx, log, nc); err != nil {
524 return fmt.Errorf("writing domains.conf: %w", err)
525 }
526 log.Info("domain added", slog.Any("domain", domain))
527 cleanupFiles = nil // All good, don't cleanup.
528 return nil
529}
530
531// DomainRemove removes domain from the config, rewriting domains.conf.
532//
533// No accounts are removed, also not when they still reference this domain.
534func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
535 log := pkglog.WithContext(ctx)
536 defer func() {
537 if rerr != nil {
538 log.Errorx("removing domain", rerr, slog.Any("domain", domain))
539 }
540 }()
541
542 Conf.dynamicMutex.Lock()
543 defer Conf.dynamicMutex.Unlock()
544
545 c := Conf.Dynamic
546 domConf, ok := c.Domains[domain.Name()]
547 if !ok {
548 return fmt.Errorf("%w: domain does not exist", ErrRequest)
549 }
550
551 // Compose new config without modifying existing data structures. If we fail, we
552 // leave no trace.
553 nc := c
554 nc.Domains = map[string]config.Domain{}
555 s := domain.Name()
556 for name, d := range c.Domains {
557 if name != s {
558 nc.Domains[name] = d
559 }
560 }
561
562 if err := writeDynamic(ctx, log, nc); err != nil {
563 return fmt.Errorf("writing domains.conf: %w", err)
564 }
565
566 // Move away any DKIM private keys to a subdirectory "old". But only if
567 // they are not in use by other domains.
568 usedKeyPaths := gatherUsedKeysPaths(nc)
569 moveAwayKeys(log, domConf.DKIM.Selectors, usedKeyPaths)
570
571 log.Info("domain removed", slog.Any("domain", domain))
572 return nil
573}
574
575func gatherUsedKeysPaths(nc config.Dynamic) map[string]bool {
576 usedKeyPaths := map[string]bool{}
577 for _, dc := range nc.Domains {
578 for _, sel := range dc.DKIM.Selectors {
579 usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
580 }
581 }
582 return usedKeyPaths
583}
584
585func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths map[string]bool) {
586 for _, sel := range sels {
587 if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
588 continue
589 }
590 src := ConfigDirPath(sel.PrivateKeyFile)
591 dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
592 _, err := os.Stat(dst)
593 if err == nil {
594 err = fmt.Errorf("destination already exists")
595 } else if os.IsNotExist(err) {
596 os.MkdirAll(filepath.Dir(dst), 0770)
597 err = os.Rename(src, dst)
598 }
599 if err != nil {
600 log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst))
601 }
602 }
603}
604
605// DomainSave calls xmodify with a shallow copy of the domain config. xmodify
606// can modify the config, but must clone all referencing data it changes.
607// xmodify may employ panic-based error handling. After xmodify returns, the
608// modified config is verified, saved and takes effect.
609func DomainSave(ctx context.Context, domainName string, xmodify func(config *config.Domain) error) (rerr error) {
610 log := pkglog.WithContext(ctx)
611 defer func() {
612 if rerr != nil {
613 log.Errorx("saving domain config", rerr)
614 }
615 }()
616
617 Conf.dynamicMutex.Lock()
618 defer Conf.dynamicMutex.Unlock()
619
620 nc := Conf.Dynamic // Shallow copy.
621 dom, ok := nc.Domains[domainName] // dom is a shallow copy.
622 if !ok {
623 return fmt.Errorf("%w: domain not present", ErrRequest)
624 }
625
626 if err := xmodify(&dom); err != nil {
627 return err
628 }
629
630 // Compose new config without modifying existing data structures. If we fail, we
631 // leave no trace.
632 nc.Domains = map[string]config.Domain{}
633 for name, d := range Conf.Dynamic.Domains {
634 nc.Domains[name] = d
635 }
636 nc.Domains[domainName] = dom
637
638 if err := writeDynamic(ctx, log, nc); err != nil {
639 return fmt.Errorf("writing domains.conf: %w", err)
640 }
641
642 log.Info("domain saved")
643 return nil
644}
645
646// ConfigSave calls xmodify with a shallow copy of the dynamic config. xmodify
647// can modify the config, but must clone all referencing data it changes.
648// xmodify may employ panic-based error handling. After xmodify returns, the
649// modified config is verified, saved and takes effect.
650func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr error) {
651 log := pkglog.WithContext(ctx)
652 defer func() {
653 if rerr != nil {
654 log.Errorx("saving config", rerr)
655 }
656 }()
657
658 Conf.dynamicMutex.Lock()
659 defer Conf.dynamicMutex.Unlock()
660
661 nc := Conf.Dynamic // Shallow copy.
662 xmodify(&nc)
663
664 if err := writeDynamic(ctx, log, nc); err != nil {
665 return fmt.Errorf("writing domains.conf: %w", err)
666 }
667
668 log.Info("config saved")
669 return nil
670}
671
672// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
673
674// DomainRecords returns text lines describing DNS records required for configuring
675// a domain.
676//
677// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
678// that caID will be suggested. If acmeAccountURI is also set, CAA records also
679// restricting issuance to that account ID will be suggested.
680func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
681 d := domain.ASCII
682 h := Conf.Static.HostnameDomain.ASCII
683
684 // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
685 // ../testdata/integration/moxmail2.sh for selecting DNS records
686 records := []string{
687 "; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
688 "; Once your setup is working, you may want to increase the TTL.",
689 "$TTL 300",
690 "",
691 }
692
693 if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
694 records = append(records,
695 `; DANE: These records indicate that a remote mail server trying to deliver email`,
696 `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
697 `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
698 `; hexadecimal hash. DANE-EE verification means only the certificate or public`,
699 `; key is verified, not whether the certificate is signed by a (centralized)`,
700 `; certificate authority (CA), is expired, or matches the host name.`,
701 `;`,
702 `; NOTE: Create the records below only once: They are for the machine, and apply`,
703 `; to all hosted domains.`,
704 )
705 if !hasDNSSEC {
706 records = append(records,
707 ";",
708 "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
709 "; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
710 "; commented out.",
711 )
712 }
713 addTLSA := func(privKey crypto.Signer) error {
714 spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
715 if err != nil {
716 return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
717 }
718 sum := sha256.Sum256(spkiBuf)
719 tlsaRecord := adns.TLSA{
720 Usage: adns.TLSAUsageDANEEE,
721 Selector: adns.TLSASelectorSPKI,
722 MatchType: adns.TLSAMatchTypeSHA256,
723 CertAssoc: sum[:],
724 }
725 var s string
726 if hasDNSSEC {
727 s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
728 } else {
729 s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
730 }
731 records = append(records, s)
732 return nil
733 }
734 for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
735 if err := addTLSA(privKey); err != nil {
736 return nil, err
737 }
738 }
739 for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
740 if err := addTLSA(privKey); err != nil {
741 return nil, err
742 }
743 }
744 records = append(records, "")
745 }
746
747 if d != h {
748 records = append(records,
749 "; For the machine, only needs to be created once, for the first domain added:",
750 "; ",
751 "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
752 "; messages (DSNs) sent from host:",
753 fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
754 "",
755 )
756 }
757 if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
758 uri := url.URL{
759 Scheme: "mailto",
760 Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false),
761 }
762 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
763 records = append(records,
764 "; For the machine, only needs to be created once, for the first domain added:",
765 "; ",
766 "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
767 fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
768 "",
769 )
770 }
771
772 records = append(records,
773 "; Deliver email for the domain to this host.",
774 fmt.Sprintf("%s. MX 10 %s.", d, h),
775 "",
776
777 "; Outgoing messages will be signed with the first two DKIM keys. The other two",
778 "; configured for backup, switching to them is just a config change.",
779 )
780 var selectors []string
781 for name := range domConf.DKIM.Selectors {
782 selectors = append(selectors, name)
783 }
784 sort.Slice(selectors, func(i, j int) bool {
785 return selectors[i] < selectors[j]
786 })
787 for _, name := range selectors {
788 sel := domConf.DKIM.Selectors[name]
789 dkimr := dkim.Record{
790 Version: "DKIM1",
791 Hashes: []string{"sha256"},
792 PublicKey: sel.Key.Public(),
793 }
794 if _, ok := sel.Key.(ed25519.PrivateKey); ok {
795 dkimr.Key = "ed25519"
796 } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
797 return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
798 }
799 txt, err := dkimr.Record()
800 if err != nil {
801 return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
802 }
803
804 if len(txt) > 100 {
805 records = append(records,
806 "; NOTE: The following strings must be added to DNS as single record.",
807 )
808 }
809 s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt))
810 records = append(records, s)
811
812 }
813 dmarcr := dmarc.DefaultRecord
814 dmarcr.Policy = "reject"
815 if domConf.DMARC != nil {
816 uri := url.URL{
817 Scheme: "mailto",
818 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
819 }
820 dmarcr.AggregateReportAddresses = []dmarc.URI{
821 {Address: uri.String(), MaxSize: 10, Unit: "m"},
822 }
823 }
824 records = append(records,
825 "",
826
827 "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
828 "; ~all means softfail for anything else, which is done instead of -all to prevent older",
829 "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
830 fmt.Sprintf(`%s. TXT "v=spf1 mx ~all"`, d),
831 "",
832
833 "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
834 "; should be rejected, and request reports. If you email through mailing lists that",
835 "; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
836 "; set the policy to p=none.",
837 fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
838 "",
839 )
840
841 if sts := domConf.MTASTS; sts != nil {
842 records = append(records,
843 "; Remote servers can use MTA-STS to verify our TLS certificate with the",
844 "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
845 "; STARTTLSTLS.",
846 fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
847 fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
848 "",
849 )
850 } else {
851 records = append(records,
852 "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
853 "; domain or because mox.conf does not have a listener with MTA-STS configured.",
854 "",
855 )
856 }
857
858 if domConf.TLSRPT != nil {
859 uri := url.URL{
860 Scheme: "mailto",
861 Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
862 }
863 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
864 records = append(records,
865 "; Request reporting about TLS failures.",
866 fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
867 "",
868 )
869 }
870
871 if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
872 records = append(records,
873 "; Client settings will reference a subdomain of the hosted domain, making it",
874 "; easier to migrate to a different server in the future by not requiring settings",
875 "; in all clients to be updated.",
876 fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
877 "",
878 )
879 }
880
881 records = append(records,
882 "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
883 fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
884 fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
885 "",
886
887 // ../rfc/6186:133 ../rfc/8314:692
888 "; For secure IMAP and submission autoconfig, point to mail host.",
889 fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
890 fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
891 "",
892 // ../rfc/6186:242
893 "; Next records specify POP3 and non-TLS ports are not to be used.",
894 "; These are optional and safe to leave out (e.g. if you have to click a lot in a",
895 "; DNS admin web interface).",
896 fmt.Sprintf(`_imap._tcp.%s. SRV 0 1 143 .`, d),
897 fmt.Sprintf(`_submission._tcp.%s. SRV 0 1 587 .`, d),
898 fmt.Sprintf(`_pop3._tcp.%s. SRV 0 1 110 .`, d),
899 fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 1 995 .`, d),
900 )
901
902 if certIssuerDomainName != "" {
903 // ../rfc/8659:18 for CAA records.
904 records = append(records,
905 "",
906 "; Optional:",
907 "; You could mark Let's Encrypt as the only Certificate Authority allowed to",
908 "; sign TLS certificates for your domain.",
909 fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
910 )
911 if acmeAccountURI != "" {
912 // ../rfc/8657:99 for accounturi.
913 // ../rfc/8657:147 for validationmethods.
914 records = append(records,
915 ";",
916 "; Optionally limit certificates for this domain to the account ID and methods used by mox.",
917 fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
918 ";",
919 "; Or alternatively only limit for email-specific subdomains, so you can use",
920 "; other accounts/methods for other subdomains.",
921 fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
922 fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
923 )
924 if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
925 records = append(records,
926 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
927 )
928 }
929 if strings.HasSuffix(h, "."+d) {
930 records = append(records,
931 ";",
932 "; And the mail hostname.",
933 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
934 )
935 }
936 } else {
937 // The string "will be suggested" is used by
938 // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
939 // as end of DNS records.
940 records = append(records,
941 ";",
942 "; Note: After starting up, once an ACME account has been created, CAA records",
943 "; that restrict issuance to the account will be suggested.",
944 )
945 }
946 }
947 return records, nil
948}
949
950// AccountAdd adds an account and an initial address and reloads the configuration.
951//
952// The new account does not have a password, so cannot yet log in. Email can be
953// delivered.
954//
955// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd.
956func AccountAdd(ctx context.Context, account, address string) (rerr error) {
957 log := pkglog.WithContext(ctx)
958 defer func() {
959 if rerr != nil {
960 log.Errorx("adding account", rerr, slog.String("account", account), slog.String("address", address))
961 }
962 }()
963
964 addr, err := smtp.ParseAddress(address)
965 if err != nil {
966 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
967 }
968
969 Conf.dynamicMutex.Lock()
970 defer Conf.dynamicMutex.Unlock()
971
972 c := Conf.Dynamic
973 if _, ok := c.Accounts[account]; ok {
974 return fmt.Errorf("%w: account already present", ErrRequest)
975 }
976
977 if err := checkAddressAvailable(addr); err != nil {
978 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
979 }
980
981 // Compose new config without modifying existing data structures. If we fail, we
982 // leave no trace.
983 nc := c
984 nc.Accounts = map[string]config.Account{}
985 for name, a := range c.Accounts {
986 nc.Accounts[name] = a
987 }
988 nc.Accounts[account] = MakeAccountConfig(addr)
989
990 if err := writeDynamic(ctx, log, nc); err != nil {
991 return fmt.Errorf("writing domains.conf: %w", err)
992 }
993 log.Info("account added", slog.String("account", account), slog.Any("address", addr))
994 return nil
995}
996
997// AccountRemove removes an account and reloads the configuration.
998func AccountRemove(ctx context.Context, account string) (rerr error) {
999 log := pkglog.WithContext(ctx)
1000 defer func() {
1001 if rerr != nil {
1002 log.Errorx("adding account", rerr, slog.String("account", account))
1003 }
1004 }()
1005
1006 Conf.dynamicMutex.Lock()
1007 defer Conf.dynamicMutex.Unlock()
1008
1009 c := Conf.Dynamic
1010 if _, ok := c.Accounts[account]; !ok {
1011 return fmt.Errorf("%w: account does not exist", ErrRequest)
1012 }
1013
1014 // Compose new config without modifying existing data structures. If we fail, we
1015 // leave no trace.
1016 nc := c
1017 nc.Accounts = map[string]config.Account{}
1018 for name, a := range c.Accounts {
1019 if name != account {
1020 nc.Accounts[name] = a
1021 }
1022 }
1023
1024 if err := writeDynamic(ctx, log, nc); err != nil {
1025 return fmt.Errorf("writing domains.conf: %w", err)
1026 }
1027 log.Info("account removed", slog.String("account", account))
1028 return nil
1029}
1030
1031// checkAddressAvailable checks that the address after canonicalization is not
1032// already configured, and that its localpart does not contain the catchall
1033// localpart separator.
1034//
1035// Must be called with config lock held.
1036func checkAddressAvailable(addr smtp.Address) error {
1037 dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]
1038 if !ok {
1039 return fmt.Errorf("domain does not exist")
1040 }
1041 lp := CanonicalLocalpart(addr.Localpart, dc)
1042 if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
1043 return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
1044 } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
1045 return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
1046 } else if _, ok := dc.Aliases[lp.String()]; ok {
1047 return fmt.Errorf("address in use as alias")
1048 }
1049 return nil
1050}
1051
1052// AddressAdd adds an email address to an account and reloads the configuration. If
1053// address starts with an @ it is treated as a catchall address for the domain.
1054func AddressAdd(ctx context.Context, address, account string) (rerr error) {
1055 log := pkglog.WithContext(ctx)
1056 defer func() {
1057 if rerr != nil {
1058 log.Errorx("adding address", rerr, slog.String("address", address), slog.String("account", account))
1059 }
1060 }()
1061
1062 Conf.dynamicMutex.Lock()
1063 defer Conf.dynamicMutex.Unlock()
1064
1065 c := Conf.Dynamic
1066 a, ok := c.Accounts[account]
1067 if !ok {
1068 return fmt.Errorf("%w: account does not exist", ErrRequest)
1069 }
1070
1071 var destAddr string
1072 if strings.HasPrefix(address, "@") {
1073 d, err := dns.ParseDomain(address[1:])
1074 if err != nil {
1075 return fmt.Errorf("%w: parsing domain: %v", ErrRequest, err)
1076 }
1077 dname := d.Name()
1078 destAddr = "@" + dname
1079 if _, ok := Conf.Dynamic.Domains[dname]; !ok {
1080 return fmt.Errorf("%w: domain does not exist", ErrRequest)
1081 } else if _, ok := Conf.accountDestinations[destAddr]; ok {
1082 return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
1083 }
1084 } else {
1085 addr, err := smtp.ParseAddress(address)
1086 if err != nil {
1087 return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
1088 }
1089
1090 if err := checkAddressAvailable(addr); err != nil {
1091 return fmt.Errorf("%w: address not available: %v", ErrRequest, err)
1092 }
1093 destAddr = addr.String()
1094 }
1095
1096 // Compose new config without modifying existing data structures. If we fail, we
1097 // leave no trace.
1098 nc := c
1099 nc.Accounts = map[string]config.Account{}
1100 for name, a := range c.Accounts {
1101 nc.Accounts[name] = a
1102 }
1103 nd := map[string]config.Destination{}
1104 for name, d := range a.Destinations {
1105 nd[name] = d
1106 }
1107 nd[destAddr] = config.Destination{}
1108 a.Destinations = nd
1109 nc.Accounts[account] = a
1110
1111 if err := writeDynamic(ctx, log, nc); err != nil {
1112 return fmt.Errorf("writing domains.conf: %w", err)
1113 }
1114 log.Info("address added", slog.String("address", address), slog.String("account", account))
1115 return nil
1116}
1117
1118// AddressRemove removes an email address and reloads the configuration.
1119// Address can be a catchall address for the domain of the form "@<domain>".
1120//
1121// If the address is member of an alias, remove it from from the alias, unless it
1122// is the last member.
1123func AddressRemove(ctx context.Context, address string) (rerr error) {
1124 log := pkglog.WithContext(ctx)
1125 defer func() {
1126 if rerr != nil {
1127 log.Errorx("removing address", rerr, slog.String("address", address))
1128 }
1129 }()
1130
1131 Conf.dynamicMutex.Lock()
1132 defer Conf.dynamicMutex.Unlock()
1133
1134 ad, ok := Conf.accountDestinations[address]
1135 if !ok {
1136 return fmt.Errorf("%w: address does not exists", ErrRequest)
1137 }
1138
1139 // Compose new config without modifying existing data structures. If we fail, we
1140 // leave no trace.
1141 a, ok := Conf.Dynamic.Accounts[ad.Account]
1142 if !ok {
1143 return fmt.Errorf("internal error: cannot find account")
1144 }
1145 na := a
1146 na.Destinations = map[string]config.Destination{}
1147 var dropped bool
1148 for destAddr, d := range a.Destinations {
1149 if destAddr != address {
1150 na.Destinations[destAddr] = d
1151 } else {
1152 dropped = true
1153 }
1154 }
1155 if !dropped {
1156 return fmt.Errorf("%w: address not removed, likely a postmaster/reporting address", ErrRequest)
1157 }
1158
1159 // Also remove matching address from FromIDLoginAddresses, composing a new slice.
1160 var fromIDLoginAddresses []string
1161 var dom dns.Domain
1162 var pa smtp.Address // For non-catchall addresses (most).
1163 var err error
1164 if strings.HasPrefix(address, "@") {
1165 dom, err = dns.ParseDomain(address[1:])
1166 if err != nil {
1167 return fmt.Errorf("%w: parsing domain for catchall address: %v", ErrRequest, err)
1168 }
1169 } else {
1170 pa, err = smtp.ParseAddress(address)
1171 if err != nil {
1172 return fmt.Errorf("%w: parsing address: %v", ErrRequest, err)
1173 }
1174 dom = pa.Domain
1175 }
1176 for i, fa := range a.ParsedFromIDLoginAddresses {
1177 if fa.Domain != dom {
1178 // Keep for different domain.
1179 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
1180 continue
1181 }
1182 if strings.HasPrefix(address, "@") {
1183 continue
1184 }
1185 dc, ok := Conf.Dynamic.Domains[dom.Name()]
1186 if !ok {
1187 return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
1188 }
1189 flp := CanonicalLocalpart(fa.Localpart, dc)
1190 alp := CanonicalLocalpart(pa.Localpart, dc)
1191 if alp != flp {
1192 // Keep for different localpart.
1193 fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
1194 }
1195 }
1196 na.FromIDLoginAddresses = fromIDLoginAddresses
1197
1198 // And remove as member from aliases configured in domains.
1199 domains := maps.Clone(Conf.Dynamic.Domains)
1200 for _, aa := range na.Aliases {
1201 if aa.SubscriptionAddress != address {
1202 continue
1203 }
1204
1205 aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
1206
1207 dom, ok := Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
1208 if !ok {
1209 return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
1210 }
1211 a, ok := dom.Aliases[aa.Alias.LocalpartStr]
1212 if !ok {
1213 return fmt.Errorf("cannot find alias %s", aliasAddr)
1214 }
1215 a.Addresses = slices.Clone(a.Addresses)
1216 a.Addresses = slices.DeleteFunc(a.Addresses, func(v string) bool { return v == address })
1217 if len(a.Addresses) == 0 {
1218 return fmt.Errorf("address is last member of alias %s, add new members or remove alias first", aliasAddr)
1219 }
1220 a.ParsedAddresses = nil // Filled when parsing config.
1221 dom.Aliases = maps.Clone(dom.Aliases)
1222 dom.Aliases[aa.Alias.LocalpartStr] = a
1223 domains[aa.Alias.Domain.Name()] = dom
1224 }
1225 na.Aliases = nil // Filled when parsing config.
1226
1227 nc := Conf.Dynamic
1228 nc.Accounts = map[string]config.Account{}
1229 for name, a := range Conf.Dynamic.Accounts {
1230 nc.Accounts[name] = a
1231 }
1232 nc.Accounts[ad.Account] = na
1233 nc.Domains = domains
1234
1235 if err := writeDynamic(ctx, log, nc); err != nil {
1236 return fmt.Errorf("writing domains.conf: %w", err)
1237 }
1238 log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
1239 return nil
1240}
1241
1242func AliasAdd(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1243 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1244 if _, ok := d.Aliases[addr.Localpart.String()]; ok {
1245 return fmt.Errorf("%w: alias already present", ErrRequest)
1246 }
1247 if d.Aliases == nil {
1248 d.Aliases = map[string]config.Alias{}
1249 }
1250 d.Aliases = maps.Clone(d.Aliases)
1251 d.Aliases[addr.Localpart.String()] = alias
1252 return nil
1253 })
1254}
1255
1256func AliasUpdate(ctx context.Context, addr smtp.Address, alias config.Alias) error {
1257 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1258 a, ok := d.Aliases[addr.Localpart.String()]
1259 if !ok {
1260 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1261 }
1262 a.PostPublic = alias.PostPublic
1263 a.ListMembers = alias.ListMembers
1264 a.AllowMsgFrom = alias.AllowMsgFrom
1265 d.Aliases = maps.Clone(d.Aliases)
1266 d.Aliases[addr.Localpart.String()] = a
1267 return nil
1268 })
1269}
1270
1271func AliasRemove(ctx context.Context, addr smtp.Address) error {
1272 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1273 _, ok := d.Aliases[addr.Localpart.String()]
1274 if !ok {
1275 return fmt.Errorf("%w: alias does not exist", ErrRequest)
1276 }
1277 d.Aliases = maps.Clone(d.Aliases)
1278 delete(d.Aliases, addr.Localpart.String())
1279 return nil
1280 })
1281}
1282
1283func AliasAddressesAdd(ctx context.Context, addr smtp.Address, addresses []string) error {
1284 if len(addresses) == 0 {
1285 return fmt.Errorf("%w: at least one address required", ErrRequest)
1286 }
1287 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1288 alias, ok := d.Aliases[addr.Localpart.String()]
1289 if !ok {
1290 return fmt.Errorf("%w: no such alias", ErrRequest)
1291 }
1292 alias.Addresses = append(slices.Clone(alias.Addresses), addresses...)
1293 alias.ParsedAddresses = nil
1294 d.Aliases = maps.Clone(d.Aliases)
1295 d.Aliases[addr.Localpart.String()] = alias
1296 return nil
1297 })
1298}
1299
1300func AliasAddressesRemove(ctx context.Context, addr smtp.Address, addresses []string) error {
1301 if len(addresses) == 0 {
1302 return fmt.Errorf("%w: need at least one address", ErrRequest)
1303 }
1304 return DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
1305 alias, ok := d.Aliases[addr.Localpart.String()]
1306 if !ok {
1307 return fmt.Errorf("%w: no such alias", ErrRequest)
1308 }
1309 alias.Addresses = slices.DeleteFunc(slices.Clone(alias.Addresses), func(addr string) bool {
1310 n := len(addresses)
1311 addresses = slices.DeleteFunc(addresses, func(a string) bool { return a == addr })
1312 return n > len(addresses)
1313 })
1314 if len(addresses) > 0 {
1315 return fmt.Errorf("%w: address not found: %s", ErrRequest, strings.Join(addresses, ", "))
1316 }
1317 alias.ParsedAddresses = nil
1318 d.Aliases = maps.Clone(d.Aliases)
1319 d.Aliases[addr.Localpart.String()] = alias
1320 return nil
1321 })
1322}
1323
1324// AccountSave updates the configuration of an account. Function xmodify is called
1325// with a shallow copy of the current configuration of the account. It must not
1326// change referencing fields (e.g. existing slice/map/pointer), they may still be
1327// in use, and the change may be rolled back. Referencing values must be copied and
1328// replaced by the modify. The function may raise a panic for error handling.
1329func AccountSave(ctx context.Context, account string, xmodify func(acc *config.Account)) (rerr error) {
1330 log := pkglog.WithContext(ctx)
1331 defer func() {
1332 if rerr != nil {
1333 log.Errorx("saving account fields", rerr, slog.String("account", account))
1334 }
1335 }()
1336
1337 Conf.dynamicMutex.Lock()
1338 defer Conf.dynamicMutex.Unlock()
1339
1340 c := Conf.Dynamic
1341 acc, ok := c.Accounts[account]
1342 if !ok {
1343 return fmt.Errorf("%w: account not present", ErrRequest)
1344 }
1345
1346 xmodify(&acc)
1347
1348 // Compose new config without modifying existing data structures. If we fail, we
1349 // leave no trace.
1350 nc := c
1351 nc.Accounts = map[string]config.Account{}
1352 for name, a := range c.Accounts {
1353 nc.Accounts[name] = a
1354 }
1355 nc.Accounts[account] = acc
1356
1357 if err := writeDynamic(ctx, log, nc); err != nil {
1358 return fmt.Errorf("writing domains.conf: %w", err)
1359 }
1360 log.Info("account fields saved", slog.String("account", account))
1361 return nil
1362}
1363
1364type TLSMode uint8
1365
1366const (
1367 TLSModeImmediate TLSMode = 0
1368 TLSModeSTARTTLS TLSMode = 1
1369 TLSModeNone TLSMode = 2
1370)
1371
1372type ProtocolConfig struct {
1373 Host dns.Domain
1374 Port int
1375 TLSMode TLSMode
1376}
1377
1378type ClientConfig struct {
1379 IMAP ProtocolConfig
1380 Submission ProtocolConfig
1381}
1382
1383// ClientConfigDomain returns a single IMAP and Submission client configuration for
1384// a domain.
1385func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
1386 var haveIMAP, haveSubmission bool
1387
1388 domConf, ok := Conf.Domain(d)
1389 if !ok {
1390 return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
1391 }
1392
1393 gather := func(l config.Listener) (done bool) {
1394 host := Conf.Static.HostnameDomain
1395 if l.Hostname != "" {
1396 host = l.HostnameDomain
1397 }
1398 if domConf.ClientSettingsDomain != "" {
1399 host = domConf.ClientSettingsDNSDomain
1400 }
1401 if !haveIMAP && l.IMAPS.Enabled {
1402 rconfig.IMAP.Host = host
1403 rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
1404 rconfig.IMAP.TLSMode = TLSModeImmediate
1405 haveIMAP = true
1406 }
1407 if !haveIMAP && l.IMAP.Enabled {
1408 rconfig.IMAP.Host = host
1409 rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
1410 rconfig.IMAP.TLSMode = TLSModeSTARTTLS
1411 if l.TLS == nil {
1412 rconfig.IMAP.TLSMode = TLSModeNone
1413 }
1414 haveIMAP = true
1415 }
1416 if !haveSubmission && l.Submissions.Enabled {
1417 rconfig.Submission.Host = host
1418 rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
1419 rconfig.Submission.TLSMode = TLSModeImmediate
1420 haveSubmission = true
1421 }
1422 if !haveSubmission && l.Submission.Enabled {
1423 rconfig.Submission.Host = host
1424 rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
1425 rconfig.Submission.TLSMode = TLSModeSTARTTLS
1426 if l.TLS == nil {
1427 rconfig.Submission.TLSMode = TLSModeNone
1428 }
1429 haveSubmission = true
1430 }
1431 return haveIMAP && haveSubmission
1432 }
1433
1434 // Look at the public listener first. Most likely the intended configuration.
1435 if public, ok := Conf.Static.Listeners["public"]; ok {
1436 if gather(public) {
1437 return
1438 }
1439 }
1440 // Go through the other listeners in consistent order.
1441 names := maps.Keys(Conf.Static.Listeners)
1442 sort.Strings(names)
1443 for _, name := range names {
1444 if gather(Conf.Static.Listeners[name]) {
1445 return
1446 }
1447 }
1448 return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
1449}
1450
1451// ClientConfigs holds the client configuration for IMAP/Submission for a
1452// domain.
1453type ClientConfigs struct {
1454 Entries []ClientConfigsEntry
1455}
1456
1457type ClientConfigsEntry struct {
1458 Protocol string
1459 Host dns.Domain
1460 Port int
1461 Listener string
1462 Note string
1463}
1464
1465// ClientConfigsDomain returns the client configs for IMAP/Submission for a
1466// domain.
1467func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
1468 domConf, ok := Conf.Domain(d)
1469 if !ok {
1470 return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
1471 }
1472
1473 c := ClientConfigs{}
1474 c.Entries = []ClientConfigsEntry{}
1475 var listeners []string
1476
1477 for name := range Conf.Static.Listeners {
1478 listeners = append(listeners, name)
1479 }
1480 sort.Slice(listeners, func(i, j int) bool {
1481 return listeners[i] < listeners[j]
1482 })
1483
1484 note := func(tls bool, requiretls bool) string {
1485 if !tls {
1486 return "plain text, no STARTTLS configured"
1487 }
1488 if requiretls {
1489 return "STARTTLS required"
1490 }
1491 return "STARTTLS optional"
1492 }
1493
1494 for _, name := range listeners {
1495 l := Conf.Static.Listeners[name]
1496 host := Conf.Static.HostnameDomain
1497 if l.Hostname != "" {
1498 host = l.HostnameDomain
1499 }
1500 if domConf.ClientSettingsDomain != "" {
1501 host = domConf.ClientSettingsDNSDomain
1502 }
1503 if l.Submissions.Enabled {
1504 c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
1505 }
1506 if l.IMAPS.Enabled {
1507 c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
1508 }
1509 if l.Submission.Enabled {
1510 c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
1511 }
1512 if l.IMAP.Enabled {
1513 c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
1514 }
1515 }
1516
1517 return c, nil
1518}
1519
1520// IPs returns ip addresses we may be listening/receiving mail on or
1521// connecting/sending from to the outside.
1522func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
1523 log := pkglog.WithContext(ctx)
1524
1525 // Try to gather all IPs we are listening on by going through the config.
1526 // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
1527 var ips []net.IP
1528 var ipv4all, ipv6all bool
1529 for _, l := range Conf.Static.Listeners {
1530 // If NATed, we don't know our external IPs.
1531 if l.IPsNATed {
1532 return nil, nil
1533 }
1534 check := l.IPs
1535 if len(l.NATIPs) > 0 {
1536 check = l.NATIPs
1537 }
1538 for _, s := range check {
1539 ip := net.ParseIP(s)
1540 if ip.IsUnspecified() {
1541 if ip.To4() != nil {
1542 ipv4all = true
1543 } else {
1544 ipv6all = true
1545 }
1546 continue
1547 }
1548 ips = append(ips, ip)
1549 }
1550 }
1551
1552 // We'll list the IPs on the interfaces. How useful is this? There is a good chance
1553 // we're listening on all addresses because of a load balancer/firewall.
1554 if ipv4all || ipv6all {
1555 ifaces, err := net.Interfaces()
1556 if err != nil {
1557 return nil, fmt.Errorf("listing network interfaces: %v", err)
1558 }
1559 for _, iface := range ifaces {
1560 if iface.Flags&net.FlagUp == 0 {
1561 continue
1562 }
1563 addrs, err := iface.Addrs()
1564 if err != nil {
1565 return nil, fmt.Errorf("listing addresses for network interface: %v", err)
1566 }
1567 if len(addrs) == 0 {
1568 continue
1569 }
1570
1571 for _, addr := range addrs {
1572 ip, _, err := net.ParseCIDR(addr.String())
1573 if err != nil {
1574 log.Errorx("bad interface addr", err, slog.Any("address", addr))
1575 continue
1576 }
1577 v4 := ip.To4() != nil
1578 if ipv4all && v4 || ipv6all && !v4 {
1579 ips = append(ips, ip)
1580 }
1581 }
1582 }
1583 }
1584
1585 if receiveOnly {
1586 return ips, nil
1587 }
1588
1589 for _, t := range Conf.Static.Transports {
1590 if t.Socks != nil {
1591 ips = append(ips, t.Socks.IPs...)
1592 }
1593 }
1594
1595 return ips, nil
1596}
1597