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