8 cryptorand "crypto/rand"
22 "golang.org/x/exp/maps"
23 "golang.org/x/exp/slog"
25 "github.com/mjl-/adns"
27 "github.com/mjl-/mox/config"
28 "github.com/mjl-/mox/dkim"
29 "github.com/mjl-/mox/dmarc"
30 "github.com/mjl-/mox/dns"
31 "github.com/mjl-/mox/junk"
32 "github.com/mjl-/mox/mtasts"
33 "github.com/mjl-/mox/smtp"
34 "github.com/mjl-/mox/tlsrpt"
37// TXTStrings returns a TXT record value as one or more quoted strings, each max
38// 100 characters. In case of multiple strings, a multi-line record is returned.
39func TXTStrings(s string) string {
53 r += "\t\t\"" + s[:n] + "\"\n"
60// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
62// selector and domain can be empty. If not, they are used in the note.
63func MakeDKIMEd25519Key(selector, domain dns.Domain) ([]byte, error) {
64 _, privKey, err := ed25519.GenerateKey(cryptorand.Reader)
66 return nil, fmt.Errorf("generating key: %w", err)
69 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
71 return nil, fmt.Errorf("marshal key: %w", err)
76 Headers: map[string]string{
77 "Note": dkimKeyNote("ed25519", selector, domain),
82 if err := pem.Encode(b, block); err != nil {
83 return nil, fmt.Errorf("encoding pem: %w", err)
88func dkimKeyNote(kind string, selector, domain dns.Domain) string {
89 s := kind + " dkim private key"
91 if selector != zero && domain != zero {
92 s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII)
94 s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339))
98// MakeDKIMEd25519Key returns a PEM buffer containing an rsa key for use with
100// selector and domain can be empty. If not, they are used in the note.
101func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
102 // 2048 bits seems reasonable in 2022, 1024 is on the low side, larger
103 // keys may not fit in UDP DNS response.
104 privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
106 return nil, fmt.Errorf("generating key: %w", err)
109 pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
111 return nil, fmt.Errorf("marshal key: %w", err)
116 Headers: map[string]string{
117 "Note": dkimKeyNote("rsa-2048", selector, domain),
122 if err := pem.Encode(b, block); err != nil {
123 return nil, fmt.Errorf("encoding pem: %w", err)
125 return b.Bytes(), nil
128// MakeAccountConfig returns a new account configuration for an email address.
129func MakeAccountConfig(addr smtp.Address) config.Account {
130 account := config.Account{
131 Domain: addr.Domain.Name(),
132 Destinations: map[string]config.Destination{
135 RejectsMailbox: "Rejects",
136 JunkFilter: &config.JunkFilter{
147 account.AutomaticJunkFlags.Enabled = true
148 account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
149 account.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
150 account.SubjectPass.Period = 12 * time.Hour
154// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
155// accountName for DMARC and TLS reports.
156func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string, withMTASTS bool) (config.Domain, []string, error) {
157 log := pkglog.WithContext(ctx)
160 year := now.Format("2006")
161 timestamp := now.Format("20060102T150405")
165 for _, p := range paths {
167 log.Check(err, "removing path for domain config", slog.String("path", p))
171 writeFile := func(path string, data []byte) error {
172 os.MkdirAll(filepath.Dir(path), 0770)
174 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
176 return fmt.Errorf("creating file %s: %s", path, err)
181 log.Check(err, "closing file after error")
182 err = os.Remove(path)
183 log.Check(err, "removing file after error", slog.String("path", path))
186 if _, err := f.Write(data); err != nil {
187 return fmt.Errorf("writing file %s: %s", path, err)
189 if err := f.Close(); err != nil {
190 return fmt.Errorf("close file: %v", err)
196 confDKIM := config.DKIM{
197 Selectors: map[string]config.Selector{},
200 addSelector := func(kind, name string, privKey []byte) error {
201 record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
202 keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
203 p := configDirPath(ConfigDynamicPath, keyPath)
204 if err := writeFile(p, privKey); err != nil {
207 paths = append(paths, p)
208 confDKIM.Selectors[name] = config.Selector{
211 // Messages in the wild have been observed with 2 hours and 1 year expiration.
213 PrivateKeyFile: keyPath,
218 addEd25519 := func(name string) error {
219 key, err := MakeDKIMEd25519Key(dns.Domain{ASCII: name}, domain)
221 return fmt.Errorf("making dkim ed25519 private key: %s", err)
223 return addSelector("ed25519", name, key)
226 addRSA := func(name string) error {
227 key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain)
229 return fmt.Errorf("making dkim rsa private key: %s", err)
231 return addSelector("rsa2048", name, key)
234 if err := addEd25519(year + "a"); err != nil {
235 return config.Domain{}, nil, err
237 if err := addRSA(year + "b"); err != nil {
238 return config.Domain{}, nil, err
240 if err := addEd25519(year + "c"); err != nil {
241 return config.Domain{}, nil, err
243 if err := addRSA(year + "d"); err != nil {
244 return config.Domain{}, nil, err
247 // We sign with the first two. In case they are misused, the switch to the other
248 // keys is easy, just change the config. Operators should make the public key field
249 // of the misused keys empty in the DNS records to disable the misused keys.
250 confDKIM.Sign = []string{year + "a", year + "b"}
252 confDomain := config.Domain{
253 ClientSettingsDomain: "mail." + domain.Name(),
254 LocalpartCatchallSeparator: "+",
256 DMARC: &config.DMARC{
257 Account: accountName,
258 Localpart: "dmarc-reports",
261 TLSRPT: &config.TLSRPT{
262 Account: accountName,
263 Localpart: "tls-reports",
269 confDomain.MTASTS = &config.MTASTS{
270 PolicyID: time.Now().UTC().Format("20060102T150405"),
271 Mode: mtasts.ModeEnforce,
272 // We start out with 24 hour, and warn in the admin interface that users should
273 // increase it to weeks once the setup works.
274 MaxAge: 24 * time.Hour,
275 MX: []string{hostname.ASCII},
282 return confDomain, rpaths, nil
285// DomainAdd adds the domain to the domains config, rewriting domains.conf and
288// accountName is used for DMARC/TLS report and potentially for the postmaster address.
289// If the account does not exist, it is created with localpart. Localpart must be
290// set only if the account does not yet exist.
291func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) {
292 log := pkglog.WithContext(ctx)
295 log.Errorx("adding domain", rerr,
296 slog.Any("domain", domain),
297 slog.String("account", accountName),
298 slog.Any("localpart", localpart))
302 Conf.dynamicMutex.Lock()
303 defer Conf.dynamicMutex.Unlock()
306 if _, ok := c.Domains[domain.Name()]; ok {
307 return fmt.Errorf("domain already present")
310 // Compose new config without modifying existing data structures. If we fail, we
313 nc.Domains = map[string]config.Domain{}
314 for name, d := range c.Domains {
318 // Only enable mta-sts for domain if there is a listener with mta-sts.
320 for _, l := range Conf.Static.Listeners {
321 if l.MTASTSHTTPS.Enabled {
327 confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
329 return fmt.Errorf("preparing domain config: %v", err)
332 for _, f := range cleanupFiles {
334 log.Check(err, "cleaning up file after error", slog.String("path", f))
338 if _, ok := c.Accounts[accountName]; ok && localpart != "" {
339 return fmt.Errorf("account already exists (leave localpart empty when using an existing account)")
340 } else if !ok && localpart == "" {
341 return fmt.Errorf("account does not yet exist (specify a localpart)")
342 } else if accountName == "" {
343 return fmt.Errorf("account name is empty")
345 nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain})
346 } else if accountName != Conf.Static.Postmaster.Account {
347 nacc := nc.Accounts[accountName]
348 nd := map[string]config.Destination{}
349 for k, v := range nacc.Destinations {
352 pmaddr := smtp.Address{Localpart: "postmaster", Domain: domain}
353 nd[pmaddr.String()] = config.Destination{}
354 nacc.Destinations = nd
355 nc.Accounts[accountName] = nacc
358 nc.Domains[domain.Name()] = confDomain
360 if err := writeDynamic(ctx, log, nc); err != nil {
361 return fmt.Errorf("writing domains.conf: %v", err)
363 log.Info("domain added", slog.Any("domain", domain))
364 cleanupFiles = nil // All good, don't cleanup.
368// DomainRemove removes domain from the config, rewriting domains.conf.
370// No accounts are removed, also not when they still reference this domain.
371func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
372 log := pkglog.WithContext(ctx)
375 log.Errorx("removing domain", rerr, slog.Any("domain", domain))
379 Conf.dynamicMutex.Lock()
380 defer Conf.dynamicMutex.Unlock()
383 domConf, ok := c.Domains[domain.Name()]
385 return fmt.Errorf("domain does not exist")
388 // Compose new config without modifying existing data structures. If we fail, we
391 nc.Domains = map[string]config.Domain{}
393 for name, d := range c.Domains {
399 if err := writeDynamic(ctx, log, nc); err != nil {
400 return fmt.Errorf("writing domains.conf: %v", err)
403 // Move away any DKIM private keys to a subdirectory "old". But only if
404 // they are not in use by other domains.
405 usedKeyPaths := map[string]bool{}
406 for _, dc := range nc.Domains {
407 for _, sel := range dc.DKIM.Selectors {
408 usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
411 for _, sel := range domConf.DKIM.Selectors {
412 if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
415 src := ConfigDirPath(sel.PrivateKeyFile)
416 dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
417 _, err := os.Stat(dst)
419 err = fmt.Errorf("destination already exists")
420 } else if os.IsNotExist(err) {
421 os.MkdirAll(filepath.Dir(dst), 0770)
422 err = os.Rename(src, dst)
425 log.Errorx("renaming dkim private key file for removed domain", err, slog.String("src", src), slog.String("dst", dst))
429 log.Info("domain removed", slog.Any("domain", domain))
433func WebserverConfigSet(ctx context.Context, domainRedirects map[string]string, webhandlers []config.WebHandler) (rerr error) {
434 log := pkglog.WithContext(ctx)
437 log.Errorx("saving webserver config", rerr)
441 Conf.dynamicMutex.Lock()
442 defer Conf.dynamicMutex.Unlock()
444 // Compose new config without modifying existing data structures. If we fail, we
447 nc.WebDomainRedirects = domainRedirects
448 nc.WebHandlers = webhandlers
450 if err := writeDynamic(ctx, log, nc); err != nil {
451 return fmt.Errorf("writing domains.conf: %v", err)
454 log.Info("webserver config saved")
458// 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.
460// DomainRecords returns text lines describing DNS records required for configuring
463// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
464// that caID will be suggested. If acmeAccountURI is also set, CAA records also
465// restricting issuance to that account ID will be suggested.
466func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
468 h := Conf.Static.HostnameDomain.ASCII
470 // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
471 // ../testdata/integration/moxmail2.sh for selecting DNS records
473 "; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
474 "; Once your setup is working, you may want to increase the TTL.",
479 if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
480 records = append(records,
481 `; DANE: These records indicate that a remote mail server trying to deliver email`,
482 `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
483 `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
484 `; hexadecimal hash. DANE-EE verification means only the certificate or public`,
485 `; key is verified, not whether the certificate is signed by a (centralized)`,
486 `; certificate authority (CA), is expired, or matches the host name.`,
488 `; NOTE: Create the records below only once: They are for the machine, and apply`,
489 `; to all hosted domains.`,
492 records = append(records,
494 "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
495 "; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
499 addTLSA := func(privKey crypto.Signer) error {
500 spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
502 return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
504 sum := sha256.Sum256(spkiBuf)
505 tlsaRecord := adns.TLSA{
506 Usage: adns.TLSAUsageDANEEE,
507 Selector: adns.TLSASelectorSPKI,
508 MatchType: adns.TLSAMatchTypeSHA256,
513 s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
515 s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
517 records = append(records, s)
520 for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
521 if err := addTLSA(privKey); err != nil {
525 for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
526 if err := addTLSA(privKey); err != nil {
530 records = append(records, "")
534 records = append(records,
535 "; For the machine, only needs to be created once, for the first domain added:",
537 "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
538 "; messages (DSNs) sent from host:",
543 if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
546 Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false),
548 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
549 records = append(records,
550 "; For the machine, only needs to be created once, for the first domain added:",
552 "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
553 fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
558 records = append(records,
559 "; Deliver email for the domain to this host.",
560 fmt.Sprintf("%s. MX 10 %s.", d, h),
563 "; Outgoing messages will be signed with the first two DKIM keys. The other two",
564 "; configured for backup, switching to them is just a config change.",
566 var selectors []string
567 for name := range domConf.DKIM.Selectors {
568 selectors = append(selectors, name)
570 sort.Slice(selectors, func(i, j int) bool {
571 return selectors[i] < selectors[j]
573 for _, name := range selectors {
574 sel := domConf.DKIM.Selectors[name]
575 dkimr := dkim.Record{
577 Hashes: []string{"sha256"},
578 PublicKey: sel.Key.Public(),
580 if _, ok := sel.Key.(ed25519.PrivateKey); ok {
581 dkimr.Key = "ed25519"
582 } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
583 return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
585 txt, err := dkimr.Record()
587 return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
591 records = append(records,
592 "; NOTE: The following strings must be added to DNS as single record.",
595 s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt))
596 records = append(records, s)
599 dmarcr := dmarc.DefaultRecord
600 dmarcr.Policy = "reject"
601 if domConf.DMARC != nil {
604 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
606 dmarcr.AggregateReportAddresses = []dmarc.URI{
607 {Address: uri.String(), MaxSize: 10, Unit: "m"},
610 records = append(records,
613 "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
614 "; ~all means softfail for anything else, which is done instead of -all to prevent older",
615 "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
616 fmt.Sprintf(`%s. TXT "v=spf1 mx ~all"`, d),
619 "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
620 "; should be rejected, and request reports. If you email through mailing lists that",
621 "; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
622 "; set the policy to p=none.",
623 fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
627 if sts := domConf.MTASTS; sts != nil {
628 records = append(records,
629 "; Remote servers can use MTA-STS to verify our TLS certificate with the",
630 "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
632 fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
633 fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
637 records = append(records,
638 "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
639 "; domain or because mox.conf does not have a listener with MTA-STS configured.",
644 if domConf.TLSRPT != nil {
647 Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
649 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
650 records = append(records,
651 "; Request reporting about TLS failures.",
652 fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
657 if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
658 records = append(records,
659 "; Client settings will reference a subdomain of the hosted domain, making it",
660 "; easier to migrate to a different server in the future by not requiring settings",
661 "; in all clients to be updated.",
662 fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
667 records = append(records,
668 "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
669 fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
670 fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
674 "; For secure IMAP and submission autoconfig, point to mail host.",
675 fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
676 fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
679 "; Next records specify POP3 and non-TLS ports are not to be used.",
680 "; These are optional and safe to leave out (e.g. if you have to click a lot in a",
681 "; DNS admin web interface).",
682 fmt.Sprintf(`_imap._tcp.%s. SRV 0 1 143 .`, d),
683 fmt.Sprintf(`_submission._tcp.%s. SRV 0 1 587 .`, d),
684 fmt.Sprintf(`_pop3._tcp.%s. SRV 0 1 110 .`, d),
685 fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 1 995 .`, d),
688 if certIssuerDomainName != "" {
690 records = append(records,
693 "; You could mark Let's Encrypt as the only Certificate Authority allowed to",
694 "; sign TLS certificates for your domain.",
695 fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
697 if acmeAccountURI != "" {
700 records = append(records,
702 "; Optionally limit certificates for this domain to the account ID and methods used by mox.",
703 fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
705 "; Or alternatively only limit for email-specific subdomains, so you can use",
706 "; other accounts/methods for other subdomains.",
707 fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
708 fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
710 if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
711 records = append(records,
712 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
715 if strings.HasSuffix(h, "."+d) {
716 records = append(records,
718 "; And the mail hostname.",
719 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
723 // The string "will be suggested" is used by
724 // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
725 // as end of DNS records.
726 records = append(records,
728 "; Note: After starting up, once an ACME account has been created, CAA records",
729 "; that restrict issuance to the account will be suggested.",
736// AccountAdd adds an account and an initial address and reloads the configuration.
738// The new account does not have a password, so cannot yet log in. Email can be
741// Catchall addresses are not supported for AccountAdd. Add separately with AddressAdd.
742func AccountAdd(ctx context.Context, account, address string) (rerr error) {
743 log := pkglog.WithContext(ctx)
746 log.Errorx("adding account", rerr, slog.String("account", account), slog.String("address", address))
750 addr, err := smtp.ParseAddress(address)
752 return fmt.Errorf("parsing email address: %v", err)
755 Conf.dynamicMutex.Lock()
756 defer Conf.dynamicMutex.Unlock()
759 if _, ok := c.Accounts[account]; ok {
760 return fmt.Errorf("account already present")
763 if err := checkAddressAvailable(addr); err != nil {
764 return fmt.Errorf("address not available: %v", err)
767 // Compose new config without modifying existing data structures. If we fail, we
770 nc.Accounts = map[string]config.Account{}
771 for name, a := range c.Accounts {
772 nc.Accounts[name] = a
774 nc.Accounts[account] = MakeAccountConfig(addr)
776 if err := writeDynamic(ctx, log, nc); err != nil {
777 return fmt.Errorf("writing domains.conf: %v", err)
779 log.Info("account added", slog.String("account", account), slog.Any("address", addr))
783// AccountRemove removes an account and reloads the configuration.
784func AccountRemove(ctx context.Context, account string) (rerr error) {
785 log := pkglog.WithContext(ctx)
788 log.Errorx("adding account", rerr, slog.String("account", account))
792 Conf.dynamicMutex.Lock()
793 defer Conf.dynamicMutex.Unlock()
796 if _, ok := c.Accounts[account]; !ok {
797 return fmt.Errorf("account does not exist")
800 // Compose new config without modifying existing data structures. If we fail, we
803 nc.Accounts = map[string]config.Account{}
804 for name, a := range c.Accounts {
806 nc.Accounts[name] = a
810 if err := writeDynamic(ctx, log, nc); err != nil {
811 return fmt.Errorf("writing domains.conf: %v", err)
813 log.Info("account removed", slog.String("account", account))
817// checkAddressAvailable checks that the address after canonicalization is not
818// already configured, and that its localpart does not contain the catchall
819// localpart separator.
821// Must be called with config lock held.
822func checkAddressAvailable(addr smtp.Address) error {
823 if dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]; !ok {
824 return fmt.Errorf("domain does not exist")
825 } else if lp, err := CanonicalLocalpart(addr.Localpart, dc); err != nil {
826 return fmt.Errorf("canonicalizing localpart: %v", err)
827 } else if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
828 return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
829 } else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
830 return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
835// AddressAdd adds an email address to an account and reloads the configuration. If
836// address starts with an @ it is treated as a catchall address for the domain.
837func AddressAdd(ctx context.Context, address, account string) (rerr error) {
838 log := pkglog.WithContext(ctx)
841 log.Errorx("adding address", rerr, slog.String("address", address), slog.String("account", account))
845 Conf.dynamicMutex.Lock()
846 defer Conf.dynamicMutex.Unlock()
849 a, ok := c.Accounts[account]
851 return fmt.Errorf("account does not exist")
855 if strings.HasPrefix(address, "@") {
856 d, err := dns.ParseDomain(address[1:])
858 return fmt.Errorf("parsing domain: %v", err)
861 destAddr = "@" + dname
862 if _, ok := Conf.Dynamic.Domains[dname]; !ok {
863 return fmt.Errorf("domain does not exist")
864 } else if _, ok := Conf.accountDestinations[destAddr]; ok {
865 return fmt.Errorf("catchall address already configured for domain")
868 addr, err := smtp.ParseAddress(address)
870 return fmt.Errorf("parsing email address: %v", err)
873 if err := checkAddressAvailable(addr); err != nil {
874 return fmt.Errorf("address not available: %v", err)
876 destAddr = addr.String()
879 // Compose new config without modifying existing data structures. If we fail, we
882 nc.Accounts = map[string]config.Account{}
883 for name, a := range c.Accounts {
884 nc.Accounts[name] = a
886 nd := map[string]config.Destination{}
887 for name, d := range a.Destinations {
890 nd[destAddr] = config.Destination{}
892 nc.Accounts[account] = a
894 if err := writeDynamic(ctx, log, nc); err != nil {
895 return fmt.Errorf("writing domains.conf: %v", err)
897 log.Info("address added", slog.String("address", address), slog.String("account", account))
901// AddressRemove removes an email address and reloads the configuration.
902func AddressRemove(ctx context.Context, address string) (rerr error) {
903 log := pkglog.WithContext(ctx)
906 log.Errorx("removing address", rerr, slog.String("address", address))
910 Conf.dynamicMutex.Lock()
911 defer Conf.dynamicMutex.Unlock()
913 ad, ok := Conf.accountDestinations[address]
915 return fmt.Errorf("address does not exists")
918 // Compose new config without modifying existing data structures. If we fail, we
920 a, ok := Conf.Dynamic.Accounts[ad.Account]
922 return fmt.Errorf("internal error: cannot find account")
925 na.Destinations = map[string]config.Destination{}
927 for destAddr, d := range a.Destinations {
928 if destAddr != address {
929 na.Destinations[destAddr] = d
935 return fmt.Errorf("address not removed, likely a postmaster/reporting address")
938 nc.Accounts = map[string]config.Account{}
939 for name, a := range Conf.Dynamic.Accounts {
940 nc.Accounts[name] = a
942 nc.Accounts[ad.Account] = na
944 if err := writeDynamic(ctx, log, nc); err != nil {
945 return fmt.Errorf("writing domains.conf: %v", err)
947 log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
951// AccountFullNameSave updates the full name for an account and reloads the configuration.
952func AccountFullNameSave(ctx context.Context, account, fullName string) (rerr error) {
953 log := pkglog.WithContext(ctx)
956 log.Errorx("saving account full name", rerr, slog.String("account", account))
960 Conf.dynamicMutex.Lock()
961 defer Conf.dynamicMutex.Unlock()
964 acc, ok := c.Accounts[account]
966 return fmt.Errorf("account not present")
969 // Compose new config without modifying existing data structures. If we fail, we
972 nc.Accounts = map[string]config.Account{}
973 for name, a := range c.Accounts {
974 nc.Accounts[name] = a
977 acc.FullName = fullName
978 nc.Accounts[account] = acc
980 if err := writeDynamic(ctx, log, nc); err != nil {
981 return fmt.Errorf("writing domains.conf: %v", err)
983 log.Info("account full name saved", slog.String("account", account))
987// DestinationSave updates a destination for an account and reloads the configuration.
988func DestinationSave(ctx context.Context, account, destName string, newDest config.Destination) (rerr error) {
989 log := pkglog.WithContext(ctx)
992 log.Errorx("saving destination", rerr,
993 slog.String("account", account),
994 slog.String("destname", destName),
995 slog.Any("destination", newDest))
999 Conf.dynamicMutex.Lock()
1000 defer Conf.dynamicMutex.Unlock()
1003 acc, ok := c.Accounts[account]
1005 return fmt.Errorf("account not present")
1008 if _, ok := acc.Destinations[destName]; !ok {
1009 return fmt.Errorf("destination not present")
1012 // Compose new config without modifying existing data structures. If we fail, we
1015 nc.Accounts = map[string]config.Account{}
1016 for name, a := range c.Accounts {
1017 nc.Accounts[name] = a
1019 nd := map[string]config.Destination{}
1020 for dn, d := range acc.Destinations {
1023 nd[destName] = newDest
1024 nacc := nc.Accounts[account]
1025 nacc.Destinations = nd
1026 nc.Accounts[account] = nacc
1028 if err := writeDynamic(ctx, log, nc); err != nil {
1029 return fmt.Errorf("writing domains.conf: %v", err)
1031 log.Info("destination saved", slog.String("account", account), slog.String("destname", destName))
1035// AccountLimitsSave saves new message sending limits for an account.
1036func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, quotaMessageSize int64) (rerr error) {
1037 log := pkglog.WithContext(ctx)
1040 log.Errorx("saving account limits", rerr, slog.String("account", account))
1044 Conf.dynamicMutex.Lock()
1045 defer Conf.dynamicMutex.Unlock()
1048 acc, ok := c.Accounts[account]
1050 return fmt.Errorf("account not present")
1053 // Compose new config without modifying existing data structures. If we fail, we
1056 nc.Accounts = map[string]config.Account{}
1057 for name, a := range c.Accounts {
1058 nc.Accounts[name] = a
1060 acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay
1061 acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay
1062 acc.QuotaMessageSize = quotaMessageSize
1063 nc.Accounts[account] = acc
1065 if err := writeDynamic(ctx, log, nc); err != nil {
1066 return fmt.Errorf("writing domains.conf: %v", err)
1068 log.Info("account limits saved", slog.String("account", account))
1075 TLSModeImmediate TLSMode = 0
1076 TLSModeSTARTTLS TLSMode = 1
1077 TLSModeNone TLSMode = 2
1080type ProtocolConfig struct {
1086type ClientConfig struct {
1088 Submission ProtocolConfig
1091// ClientConfigDomain returns a single IMAP and Submission client configuration for
1093func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
1094 var haveIMAP, haveSubmission bool
1096 domConf, ok := Conf.Domain(d)
1098 return ClientConfig{}, fmt.Errorf("unknown domain")
1101 gather := func(l config.Listener) (done bool) {
1102 host := Conf.Static.HostnameDomain
1103 if l.Hostname != "" {
1104 host = l.HostnameDomain
1106 if domConf.ClientSettingsDomain != "" {
1107 host = domConf.ClientSettingsDNSDomain
1109 if !haveIMAP && l.IMAPS.Enabled {
1110 rconfig.IMAP.Host = host
1111 rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
1112 rconfig.IMAP.TLSMode = TLSModeImmediate
1115 if !haveIMAP && l.IMAP.Enabled {
1116 rconfig.IMAP.Host = host
1117 rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
1118 rconfig.IMAP.TLSMode = TLSModeSTARTTLS
1120 rconfig.IMAP.TLSMode = TLSModeNone
1124 if !haveSubmission && l.Submissions.Enabled {
1125 rconfig.Submission.Host = host
1126 rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
1127 rconfig.Submission.TLSMode = TLSModeImmediate
1128 haveSubmission = true
1130 if !haveSubmission && l.Submission.Enabled {
1131 rconfig.Submission.Host = host
1132 rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
1133 rconfig.Submission.TLSMode = TLSModeSTARTTLS
1135 rconfig.Submission.TLSMode = TLSModeNone
1137 haveSubmission = true
1139 return haveIMAP && haveSubmission
1142 // Look at the public listener first. Most likely the intended configuration.
1143 if public, ok := Conf.Static.Listeners["public"]; ok {
1148 // Go through the other listeners in consistent order.
1149 names := maps.Keys(Conf.Static.Listeners)
1151 for _, name := range names {
1152 if gather(Conf.Static.Listeners[name]) {
1156 return ClientConfig{}, fmt.Errorf("no listeners found for imap and/or submission")
1159// ClientConfigs holds the client configuration for IMAP/Submission for a
1161type ClientConfigs struct {
1162 Entries []ClientConfigsEntry
1165type ClientConfigsEntry struct {
1173// ClientConfigsDomain returns the client configs for IMAP/Submission for a
1175func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
1176 domConf, ok := Conf.Domain(d)
1178 return ClientConfigs{}, fmt.Errorf("unknown domain")
1181 c := ClientConfigs{}
1182 c.Entries = []ClientConfigsEntry{}
1183 var listeners []string
1185 for name := range Conf.Static.Listeners {
1186 listeners = append(listeners, name)
1188 sort.Slice(listeners, func(i, j int) bool {
1189 return listeners[i] < listeners[j]
1192 note := func(tls bool, requiretls bool) string {
1194 return "plain text, no STARTTLS configured"
1197 return "STARTTLS required"
1199 return "STARTTLS optional"
1202 for _, name := range listeners {
1203 l := Conf.Static.Listeners[name]
1204 host := Conf.Static.HostnameDomain
1205 if l.Hostname != "" {
1206 host = l.HostnameDomain
1208 if domConf.ClientSettingsDomain != "" {
1209 host = domConf.ClientSettingsDNSDomain
1211 if l.Submissions.Enabled {
1212 c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
1214 if l.IMAPS.Enabled {
1215 c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
1217 if l.Submission.Enabled {
1218 c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
1221 c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
1228// IPs returns ip addresses we may be listening/receiving mail on or
1229// connecting/sending from to the outside.
1230func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
1231 log := pkglog.WithContext(ctx)
1233 // Try to gather all IPs we are listening on by going through the config.
1234 // If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
1236 var ipv4all, ipv6all bool
1237 for _, l := range Conf.Static.Listeners {
1238 // If NATed, we don't know our external IPs.
1243 if len(l.NATIPs) > 0 {
1246 for _, s := range check {
1247 ip := net.ParseIP(s)
1248 if ip.IsUnspecified() {
1249 if ip.To4() != nil {
1256 ips = append(ips, ip)
1260 // We'll list the IPs on the interfaces. How useful is this? There is a good chance
1261 // we're listening on all addresses because of a load balancer/firewall.
1262 if ipv4all || ipv6all {
1263 ifaces, err := net.Interfaces()
1265 return nil, fmt.Errorf("listing network interfaces: %v", err)
1267 for _, iface := range ifaces {
1268 if iface.Flags&net.FlagUp == 0 {
1271 addrs, err := iface.Addrs()
1273 return nil, fmt.Errorf("listing addresses for network interface: %v", err)
1275 if len(addrs) == 0 {
1279 for _, addr := range addrs {
1280 ip, _, err := net.ParseCIDR(addr.String())
1282 log.Errorx("bad interface addr", err, slog.Any("address", addr))
1285 v4 := ip.To4() != nil
1286 if ipv4all && v4 || ipv6all && !v4 {
1287 ips = append(ips, ip)
1297 for _, t := range Conf.Static.Transports {
1299 ips = append(ips, t.Socks.IPs...)