1// Package webadmin is a web app for the mox administrator for viewing and changing
2// the configuration, like creating/removing accounts, viewing DMARC and TLS
3// reports, check DNS records for a domain, change the webserver configuration,
4// etc.
5package webadmin
6
7import (
8 "bufio"
9 "bytes"
10 "context"
11 "crypto/ed25519"
12 "crypto/rsa"
13 "crypto/tls"
14 "crypto/x509"
15 "encoding/base64"
16 "encoding/json"
17 "errors"
18 "fmt"
19 "io"
20 "net"
21 "net/http"
22 "net/url"
23 "os"
24 "reflect"
25 "runtime/debug"
26 "sort"
27 "strings"
28 "sync"
29 "time"
30
31 _ "embed"
32
33 "golang.org/x/crypto/bcrypt"
34
35 "github.com/mjl-/bstore"
36 "github.com/mjl-/sherpa"
37 "github.com/mjl-/sherpadoc"
38 "github.com/mjl-/sherpaprom"
39
40 "github.com/mjl-/mox/config"
41 "github.com/mjl-/mox/dkim"
42 "github.com/mjl-/mox/dmarc"
43 "github.com/mjl-/mox/dmarcdb"
44 "github.com/mjl-/mox/dmarcrpt"
45 "github.com/mjl-/mox/dns"
46 "github.com/mjl-/mox/dnsbl"
47 "github.com/mjl-/mox/metrics"
48 "github.com/mjl-/mox/mlog"
49 mox "github.com/mjl-/mox/mox-"
50 "github.com/mjl-/mox/moxvar"
51 "github.com/mjl-/mox/mtasts"
52 "github.com/mjl-/mox/mtastsdb"
53 "github.com/mjl-/mox/publicsuffix"
54 "github.com/mjl-/mox/queue"
55 "github.com/mjl-/mox/smtp"
56 "github.com/mjl-/mox/spf"
57 "github.com/mjl-/mox/store"
58 "github.com/mjl-/mox/tlsrpt"
59 "github.com/mjl-/mox/tlsrptdb"
60)
61
62var xlog = mlog.New("webadmin")
63
64//go:embed adminapi.json
65var adminapiJSON []byte
66
67//go:embed admin.html
68var adminHTML []byte
69
70var adminDoc = mustParseAPI("admin", adminapiJSON)
71
72var adminSherpaHandler http.Handler
73
74func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
75 err := json.Unmarshal(buf, &doc)
76 if err != nil {
77 xlog.Fatalx("parsing api docs", err, mlog.Field("api", api))
78 }
79 return doc
80}
81
82func init() {
83 collector, err := sherpaprom.NewCollector("moxadmin", nil)
84 if err != nil {
85 xlog.Fatalx("creating sherpa prometheus collector", err)
86 }
87
88 adminSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Admin{}, &adminDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
89 if err != nil {
90 xlog.Fatalx("sherpa handler", err)
91 }
92}
93
94// Admin exports web API functions for the admin web interface. All its methods are
95// exported under api/. Function calls require valid HTTP Authentication
96// credentials of a user.
97type Admin struct{}
98
99// We keep a cache for authentication so we don't bcrypt for each incoming HTTP request with HTTP basic auth.
100// We keep track of the last successful password hash and Authorization header.
101// The cache is cleared periodically, see below.
102var authCache struct {
103 sync.Mutex
104 lastSuccessHash, lastSuccessAuth string
105}
106
107// started when we start serving. not at package init time, because we don't want
108// to make goroutines that early.
109func ManageAuthCache() {
110 for {
111 authCache.Lock()
112 authCache.lastSuccessHash = ""
113 authCache.lastSuccessAuth = ""
114 authCache.Unlock()
115 time.Sleep(15 * time.Minute)
116 }
117}
118
119// check whether authentication from the config (passwordfile with bcrypt hash)
120// matches the authorization header "authHdr". we don't care about any username.
121// on (auth) failure, a http response is sent and false returned.
122func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWriter, r *http.Request) bool {
123 log := xlog.WithContext(ctx)
124
125 respondAuthFail := func() bool {
126 // note: browsers don't display the realm to prevent users getting confused by malicious realm messages.
127 w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`)
128 http.Error(w, "http 401 - unauthorized - mox admin - login with empty username and admin password", http.StatusUnauthorized)
129 return false
130 }
131
132 authResult := "error"
133 start := time.Now()
134 var addr *net.TCPAddr
135 defer func() {
136 metrics.AuthenticationInc("webadmin", "httpbasic", authResult)
137 if authResult == "ok" && addr != nil {
138 mox.LimiterFailedAuth.Reset(addr.IP, start)
139 }
140 }()
141
142 var err error
143 var remoteIP net.IP
144 addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr)
145 if err != nil {
146 log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr))
147 } else if addr != nil {
148 remoteIP = addr.IP
149 }
150 if remoteIP != nil && !mox.LimiterFailedAuth.Add(remoteIP, start, 1) {
151 metrics.AuthenticationRatelimitedInc("webadmin")
152 http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
153 return false
154 }
155
156 authHdr := r.Header.Get("Authorization")
157 if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" {
158 return respondAuthFail()
159 }
160 buf, err := os.ReadFile(passwordfile)
161 if err != nil {
162 log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile))
163 return respondAuthFail()
164 }
165 passwordhash := strings.TrimSpace(string(buf))
166 authCache.Lock()
167 defer authCache.Unlock()
168 if passwordhash != "" && passwordhash == authCache.lastSuccessHash && authHdr != "" && authCache.lastSuccessAuth == authHdr {
169 authResult = "ok"
170 return true
171 }
172 auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic "))
173 if err != nil {
174 return respondAuthFail()
175 }
176 t := strings.SplitN(string(auth), ":", 2)
177 if len(t) != 2 || len(t[1]) < 8 {
178 log.Info("failed authentication attempt", mlog.Field("username", "admin"), mlog.Field("remote", remoteIP))
179 return respondAuthFail()
180 }
181 if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil {
182 authResult = "badcreds"
183 log.Info("failed authentication attempt", mlog.Field("username", "admin"), mlog.Field("remote", remoteIP))
184 return respondAuthFail()
185 }
186 authCache.lastSuccessHash = passwordhash
187 authCache.lastSuccessAuth = authHdr
188 authResult = "ok"
189 return true
190}
191
192func Handle(w http.ResponseWriter, r *http.Request) {
193 ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
194 if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), w, r) {
195 // Response already sent.
196 return
197 }
198
199 if lw, ok := w.(interface{ AddField(f mlog.Pair) }); ok {
200 lw.AddField(mlog.Field("authadmin", true))
201 }
202
203 if r.Method == "GET" && r.URL.Path == "/" {
204 w.Header().Set("Content-Type", "text/html; charset=utf-8")
205 w.Header().Set("Cache-Control", "no-cache; max-age=0")
206 // We typically return the embedded admin.html, but during development it's handy
207 // to load from disk.
208 f, err := os.Open("webadmin/admin.html")
209 if err == nil {
210 defer f.Close()
211 _, _ = io.Copy(w, f)
212 } else {
213 _, _ = w.Write(adminHTML)
214 }
215 return
216 }
217 adminSherpaHandler.ServeHTTP(w, r.WithContext(ctx))
218}
219
220func xcheckf(ctx context.Context, err error, format string, args ...any) {
221 if err == nil {
222 return
223 }
224 msg := fmt.Sprintf(format, args...)
225 errmsg := fmt.Sprintf("%s: %s", msg, err)
226 xlog.WithContext(ctx).Errorx(msg, err)
227 panic(&sherpa.Error{Code: "server:error", Message: errmsg})
228}
229
230func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
231 if err == nil {
232 return
233 }
234 msg := fmt.Sprintf(format, args...)
235 errmsg := fmt.Sprintf("%s: %s", msg, err)
236 xlog.WithContext(ctx).Errorx(msg, err)
237 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
238}
239
240type Result struct {
241 Errors []string
242 Warnings []string
243 Instructions []string
244}
245
246type TLSCheckResult struct {
247 Result
248}
249
250type IPRevCheckResult struct {
251 Hostname dns.Domain // This hostname, IPs must resolve back to this.
252 IPNames map[string][]string // IP to names.
253 Result
254}
255
256type MX struct {
257 Host string
258 Pref int
259 IPs []string
260}
261
262type MXCheckResult struct {
263 Records []MX
264 Result
265}
266
267type SPFRecord struct {
268 spf.Record
269}
270
271type SPFCheckResult struct {
272 DomainTXT string
273 DomainRecord *SPFRecord
274 HostTXT string
275 HostRecord *SPFRecord
276 Result
277}
278
279type DKIMCheckResult struct {
280 Records []DKIMRecord
281 Result
282}
283
284type DKIMRecord struct {
285 Selector string
286 TXT string
287 Record *dkim.Record
288}
289
290type DMARCRecord struct {
291 dmarc.Record
292}
293
294type DMARCCheckResult struct {
295 Domain string
296 TXT string
297 Record *DMARCRecord
298 Result
299}
300
301type TLSRPTRecord struct {
302 tlsrpt.Record
303}
304
305type TLSRPTCheckResult struct {
306 TXT string
307 Record *TLSRPTRecord
308 Result
309}
310
311type MTASTSRecord struct {
312 mtasts.Record
313}
314type MTASTSCheckResult struct {
315 CNAMEs []string
316 TXT string
317 Record *MTASTSRecord
318 PolicyText string
319 Policy *mtasts.Policy
320 Result
321}
322
323type SRVConfCheckResult struct {
324 SRVs map[string][]*net.SRV // Service (e.g. "_imaps") to records.
325 Result
326}
327
328type AutoconfCheckResult struct {
329 IPs []string
330 Result
331}
332
333type AutodiscoverSRV struct {
334 net.SRV
335 IPs []string
336}
337
338type AutodiscoverCheckResult struct {
339 Records []AutodiscoverSRV
340 Result
341}
342
343// CheckResult is the analysis of a domain, its actual configuration (DNS, TLS,
344// connectivity) and the mox configuration. It includes configuration instructions
345// (e.g. DNS records), and warnings and errors encountered.
346type CheckResult struct {
347 Domain string
348 IPRev IPRevCheckResult
349 MX MXCheckResult
350 TLS TLSCheckResult
351 SPF SPFCheckResult
352 DKIM DKIMCheckResult
353 DMARC DMARCCheckResult
354 TLSRPT TLSRPTCheckResult
355 MTASTS MTASTSCheckResult
356 SRVConf SRVConfCheckResult
357 Autoconf AutoconfCheckResult
358 Autodiscover AutodiscoverCheckResult
359}
360
361// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
362func logPanic(ctx context.Context) {
363 x := recover()
364 if x == nil {
365 return
366 }
367 log := xlog.WithContext(ctx)
368 log.Error("recover from panic", mlog.Field("panic", x))
369 debug.PrintStack()
370 metrics.PanicInc(metrics.Webadmin)
371}
372
373// return IPs we may be listening on.
374func xlistenIPs(ctx context.Context, receiveOnly bool) []net.IP {
375 ips, err := mox.IPs(ctx, receiveOnly)
376 xcheckf(ctx, err, "listing ips")
377 return ips
378}
379
380// return IPs from which we may be sending.
381func xsendingIPs(ctx context.Context) []net.IP {
382 ips, err := mox.IPs(ctx, false)
383 xcheckf(ctx, err, "listing ips")
384 return ips
385}
386
387// CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,
388// SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover.
389func (Admin) CheckDomain(ctx context.Context, domainName string) (r CheckResult) {
390 // todo future: should run these checks without a DNS cache so recent changes are picked up.
391
392 resolver := dns.StrictResolver{Pkg: "check"}
393 dialer := &net.Dialer{Timeout: 10 * time.Second}
394 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
395 defer cancel()
396 return checkDomain(nctx, resolver, dialer, domainName)
397}
398
399func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer, domainName string) (r CheckResult) {
400 domain, err := dns.ParseDomain(domainName)
401 xcheckuserf(ctx, err, "parsing domain")
402
403 domConf, ok := mox.Conf.Domain(domain)
404 if !ok {
405 panic(&sherpa.Error{Code: "user:notFound", Message: "domain not found"})
406 }
407
408 listenIPs := xlistenIPs(ctx, true)
409 isListenIP := func(ip net.IP) bool {
410 for _, lip := range listenIPs {
411 if ip.Equal(lip) {
412 return true
413 }
414 }
415 return false
416 }
417
418 addf := func(l *[]string, format string, args ...any) {
419 *l = append(*l, fmt.Sprintf(format, args...))
420 }
421
422 // host must be an absolute dns name, ending with a dot.
423 lookupIPs := func(errors *[]string, host string) (ips []string, ourIPs, notOurIPs []net.IP, rerr error) {
424 addrs, err := resolver.LookupHost(ctx, host)
425 if err != nil {
426 addf(errors, "Looking up %q: %s", host, err)
427 return nil, nil, nil, err
428 }
429 for _, addr := range addrs {
430 ip := net.ParseIP(addr)
431 if ip == nil {
432 addf(errors, "Bad IP %q", addr)
433 continue
434 }
435 ips = append(ips, ip.String())
436 if isListenIP(ip) {
437 ourIPs = append(ourIPs, ip)
438 } else {
439 notOurIPs = append(notOurIPs, ip)
440 }
441 }
442 return ips, ourIPs, notOurIPs, nil
443 }
444
445 checkTLS := func(errors *[]string, host string, ips []string, port string) {
446 d := tls.Dialer{
447 NetDialer: dialer,
448 Config: &tls.Config{
449 ServerName: host,
450 MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
451 RootCAs: mox.Conf.Static.TLS.CertPool,
452 },
453 }
454 for _, ip := range ips {
455 conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, port))
456 if err != nil {
457 addf(errors, "TLS connection to hostname %q, IP %q: %s", host, ip, err)
458 } else {
459 conn.Close()
460 }
461 }
462 }
463
464 // If at least one listener with SMTP enabled has unspecified NATed IPs, we'll skip
465 // some checks related to these IPs.
466 var isNAT, isUnspecifiedNAT bool
467 for _, l := range mox.Conf.Static.Listeners {
468 if !l.SMTP.Enabled {
469 continue
470 }
471 if l.IPsNATed {
472 isUnspecifiedNAT = true
473 isNAT = true
474 }
475 if len(l.NATIPs) > 0 {
476 isNAT = true
477 }
478 }
479
480 var wg sync.WaitGroup
481
482 // IPRev
483 wg.Add(1)
484 go func() {
485 defer logPanic(ctx)
486 defer wg.Done()
487
488 // For each mox.Conf.SpecifiedSMTPListenIPs and all NATIPs, and each IP for
489 // mox.Conf.HostnameDomain, check if they resolve back to the host name.
490 hostIPs := map[dns.Domain][]net.IP{}
491 ips, err := resolver.LookupIP(ctx, "ip", mox.Conf.Static.HostnameDomain.ASCII+".")
492 if err != nil {
493 addf(&r.IPRev.Errors, "Looking up IPs for hostname: %s", err)
494 }
495
496 gatherMoreIPs := func(publicIPs []net.IP) {
497 nextip:
498 for _, ip := range publicIPs {
499 for _, xip := range ips {
500 if ip.Equal(xip) {
501 continue nextip
502 }
503 }
504 ips = append(ips, ip)
505 }
506 }
507 if !isNAT {
508 gatherMoreIPs(mox.Conf.Static.SpecifiedSMTPListenIPs)
509 }
510 for _, l := range mox.Conf.Static.Listeners {
511 if !l.SMTP.Enabled {
512 continue
513 }
514 var natips []net.IP
515 for _, ip := range l.NATIPs {
516 natips = append(natips, net.ParseIP(ip))
517 }
518 gatherMoreIPs(natips)
519 }
520 hostIPs[mox.Conf.Static.HostnameDomain] = ips
521
522 iplist := func(ips []net.IP) string {
523 var ipstrs []string
524 for _, ip := range ips {
525 ipstrs = append(ipstrs, ip.String())
526 }
527 return strings.Join(ipstrs, ", ")
528 }
529
530 r.IPRev.Hostname = mox.Conf.Static.HostnameDomain
531 r.IPRev.Instructions = []string{
532 fmt.Sprintf("Ensure IPs %s have reverse address %s.", iplist(ips), mox.Conf.Static.HostnameDomain.ASCII),
533 }
534
535 // If we have a socks transport, also check its host and IP.
536 for tname, t := range mox.Conf.Static.Transports {
537 if t.Socks != nil {
538 hostIPs[t.Socks.Hostname] = append(hostIPs[t.Socks.Hostname], t.Socks.IPs...)
539 instr := fmt.Sprintf("For SOCKS transport %s, ensure IPs %s have reverse address %s.", tname, iplist(t.Socks.IPs), t.Socks.Hostname)
540 r.IPRev.Instructions = append(r.IPRev.Instructions, instr)
541 }
542 }
543
544 type result struct {
545 Host dns.Domain
546 IP string
547 Addrs []string
548 Err error
549 }
550 results := make(chan result)
551 n := 0
552 for host, ips := range hostIPs {
553 for _, ip := range ips {
554 n++
555 s := ip.String()
556 host := host
557 go func() {
558 addrs, err := resolver.LookupAddr(ctx, s)
559 results <- result{host, s, addrs, err}
560 }()
561 }
562 }
563 r.IPRev.IPNames = map[string][]string{}
564 for i := 0; i < n; i++ {
565 lr := <-results
566 host, addrs, ip, err := lr.Host, lr.Addrs, lr.IP, lr.Err
567 if err != nil {
568 addf(&r.IPRev.Errors, "Looking up reverse name for %s of %s: %v", ip, host, err)
569 continue
570 }
571 if len(addrs) != 1 {
572 addf(&r.IPRev.Errors, "Expected exactly 1 name for %s of %s, got %d (%v)", ip, host, len(addrs), addrs)
573 }
574 var match bool
575 for i, a := range addrs {
576 a = strings.TrimRight(a, ".")
577 addrs[i] = a
578 ad, err := dns.ParseDomain(a)
579 if err != nil {
580 addf(&r.IPRev.Errors, "Parsing reverse name %q for %s: %v", a, ip, err)
581 }
582 if ad == host {
583 match = true
584 }
585 }
586 if !match {
587 addf(&r.IPRev.Errors, "Reverse name(s) %s for ip %s do not match hostname %s, which will cause other mail servers to reject incoming messages from this IP.", strings.Join(addrs, ","), ip, host)
588 }
589 r.IPRev.IPNames[ip] = addrs
590 }
591
592 // Linux machines are often initially set up with a loopback IP for the hostname in
593 // /etc/hosts, presumably because it isn't known if their external IPs are static.
594 // For mail servers, they should certainly be static. The quickstart would also
595 // have warned about this, but could have been missed/ignored.
596 for _, ip := range ips {
597 if ip.IsLoopback() {
598 addf(&r.IPRev.Errors, "Hostname %s resolves to loopback IP %s, this will likely prevent email delivery to local accounts from working. The loopback IP was probably configured in /etc/hosts at system installation time. Replace the loopback IP with your actual external IPs in /etc/hosts.", mox.Conf.Static.HostnameDomain, ip.String())
599 }
600 }
601 }()
602
603 // MX
604 wg.Add(1)
605 go func() {
606 defer logPanic(ctx)
607 defer wg.Done()
608
609 mxs, err := resolver.LookupMX(ctx, domain.ASCII+".")
610 if err != nil {
611 addf(&r.MX.Errors, "Looking up MX records for %s: %s", domain, err)
612 }
613 r.MX.Records = make([]MX, len(mxs))
614 for i, mx := range mxs {
615 r.MX.Records[i] = MX{mx.Host, int(mx.Pref), nil}
616 }
617 if len(mxs) == 1 && mxs[0].Host == "." {
618 addf(&r.MX.Errors, `MX records consists of explicit null mx record (".") indicating that domain does not accept email.`)
619 return
620 }
621 for i, mx := range mxs {
622 ips, ourIPs, notOurIPs, err := lookupIPs(&r.MX.Errors, mx.Host)
623 if err != nil {
624 addf(&r.MX.Errors, "Looking up IPs for mx host %q: %s", mx.Host, err)
625 }
626 r.MX.Records[i].IPs = ips
627 if isUnspecifiedNAT {
628 continue
629 }
630 if len(ourIPs) == 0 {
631 addf(&r.MX.Errors, "None of the IPs that mx %q points to is ours: %v", mx.Host, notOurIPs)
632 } else if len(notOurIPs) > 0 {
633 addf(&r.MX.Errors, "Some of the IPs that mx %q points to are not ours: %v", mx.Host, notOurIPs)
634 }
635
636 }
637 r.MX.Instructions = []string{
638 fmt.Sprintf("Ensure a DNS MX record like the following exists:\n\n\t%s MX 10 %s\n\nWithout the trailing dot, the name would be interpreted as relative to the domain.", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+"."),
639 }
640 }()
641
642 // TLS, mostly checking certificate expiration and CA trust.
643 // todo: should add checks about the listeners (which aren't specific to domains) somewhere else, not on the domain page with this checkDomain call. i.e. submissions, imap starttls, imaps.
644 wg.Add(1)
645 go func() {
646 defer logPanic(ctx)
647 defer wg.Done()
648
649 // MTA-STS, autoconfig, autodiscover are checked in their sections.
650
651 // Dial a single MX host with given IP and perform STARTTLS handshake.
652 dialSMTPSTARTTLS := func(host, ip string) error {
653 conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, "25"))
654 if err != nil {
655 return err
656 }
657 defer func() {
658 if conn != nil {
659 conn.Close()
660 }
661 }()
662
663 end := time.Now().Add(10 * time.Second)
664 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
665 defer cancel()
666 err = conn.SetDeadline(end)
667 xlog.WithContext(ctx).Check(err, "setting deadline")
668
669 br := bufio.NewReader(conn)
670 _, err = br.ReadString('\n')
671 if err != nil {
672 return fmt.Errorf("reading SMTP banner from remote: %s", err)
673 }
674 if _, err := fmt.Fprintf(conn, "EHLO moxtest\r\n"); err != nil {
675 return fmt.Errorf("writing SMTP EHLO to remote: %s", err)
676 }
677 for {
678 line, err := br.ReadString('\n')
679 if err != nil {
680 return fmt.Errorf("reading SMTP EHLO response from remote: %s", err)
681 }
682 if strings.HasPrefix(line, "250-") {
683 continue
684 }
685 if strings.HasPrefix(line, "250 ") {
686 break
687 }
688 return fmt.Errorf("unexpected response to SMTP EHLO from remote: %q", strings.TrimSuffix(line, "\r\n"))
689 }
690 if _, err := fmt.Fprintf(conn, "STARTTLS\r\n"); err != nil {
691 return fmt.Errorf("writing SMTP STARTTLS to remote: %s", err)
692 }
693 line, err := br.ReadString('\n')
694 if err != nil {
695 return fmt.Errorf("reading response to SMTP STARTTLS from remote: %s", err)
696 }
697 if !strings.HasPrefix(line, "220 ") {
698 return fmt.Errorf("SMTP STARTTLS response from remote not 220 OK: %q", strings.TrimSuffix(line, "\r\n"))
699 }
700 config := &tls.Config{
701 ServerName: host,
702 RootCAs: mox.Conf.Static.TLS.CertPool,
703 }
704 tlsconn := tls.Client(conn, config)
705 if err := tlsconn.HandshakeContext(cctx); err != nil {
706 return fmt.Errorf("TLS handshake after SMTP STARTTLS: %s", err)
707 }
708 cancel()
709 conn.Close()
710 conn = nil
711 return nil
712 }
713
714 checkSMTPSTARTTLS := func() {
715 // Initial errors are ignored, will already have been warned about by MX checks.
716 mxs, err := resolver.LookupMX(ctx, domain.ASCII+".")
717 if err != nil {
718 return
719 }
720 if len(mxs) == 1 && mxs[0].Host == "." {
721 return
722 }
723 for _, mx := range mxs {
724 ips, _, _, err := lookupIPs(&r.MX.Errors, mx.Host)
725 if err != nil {
726 continue
727 }
728
729 for _, ip := range ips {
730 if err := dialSMTPSTARTTLS(mx.Host, ip); err != nil {
731 addf(&r.TLS.Errors, "SMTP connection with STARTTLS to MX hostname %q IP %s: %s", mx.Host, ip, err)
732 }
733 }
734 }
735 }
736
737 checkSMTPSTARTTLS()
738
739 }()
740
741 // SPF
742 // todo: add warnings if we have Transports with submission? admin should ensure their IPs are in the SPF record. it may be an IP(net), or an include. that means we cannot easily check for it. and should we first check the transport can be used from this domain (or an account that has this domain?). also see DKIM.
743 wg.Add(1)
744 go func() {
745 defer logPanic(ctx)
746 defer wg.Done()
747
748 // Verify a domain with the configured IPs that do SMTP.
749 verifySPF := func(kind string, domain dns.Domain) (string, *SPFRecord, spf.Record) {
750 _, txt, record, err := spf.Lookup(ctx, resolver, domain)
751 if err != nil {
752 addf(&r.SPF.Errors, "Looking up %s SPF record: %s", kind, err)
753 }
754 var xrecord *SPFRecord
755 if record != nil {
756 xrecord = &SPFRecord{*record}
757 }
758
759 spfr := spf.Record{
760 Version: "spf1",
761 }
762
763 checkSPFIP := func(ip net.IP) {
764 mechanism := "ip4"
765 if ip.To4() == nil {
766 mechanism = "ip6"
767 }
768 spfr.Directives = append(spfr.Directives, spf.Directive{Mechanism: mechanism, IP: ip})
769
770 if record == nil {
771 return
772 }
773
774 args := spf.Args{
775 RemoteIP: ip,
776 MailFromLocalpart: "postmaster",
777 MailFromDomain: domain,
778 HelloDomain: dns.IPDomain{Domain: domain},
779 LocalIP: net.ParseIP("127.0.0.1"),
780 LocalHostname: dns.Domain{ASCII: "localhost"},
781 }
782 status, mechanism, expl, err := spf.Evaluate(ctx, record, resolver, args)
783 if err != nil {
784 addf(&r.SPF.Errors, "Evaluating IP %q against %s SPF record: %s", ip, kind, err)
785 } else if status != spf.StatusPass {
786 addf(&r.SPF.Errors, "IP %q does not pass %s SPF evaluation, status not \"pass\" but %q (mechanism %q, explanation %q)", ip, kind, status, mechanism, expl)
787 }
788 }
789
790 for _, l := range mox.Conf.Static.Listeners {
791 if !l.SMTP.Enabled || l.IPsNATed {
792 continue
793 }
794 ips := l.IPs
795 if len(l.NATIPs) > 0 {
796 ips = l.NATIPs
797 }
798 for _, ipstr := range ips {
799 ip := net.ParseIP(ipstr)
800 checkSPFIP(ip)
801 }
802 }
803 for _, t := range mox.Conf.Static.Transports {
804 if t.Socks != nil {
805 for _, ip := range t.Socks.IPs {
806 checkSPFIP(ip)
807 }
808 }
809 }
810
811 spfr.Directives = append(spfr.Directives, spf.Directive{Qualifier: "-", Mechanism: "all"})
812 return txt, xrecord, spfr
813 }
814
815 // Check SPF record for domain.
816 var dspfr spf.Record
817 r.SPF.DomainTXT, r.SPF.DomainRecord, dspfr = verifySPF("domain", domain)
818 // todo: possibly check all hosts for MX records? assuming they are also sending mail servers.
819 r.SPF.HostTXT, r.SPF.HostRecord, _ = verifySPF("host", mox.Conf.Static.HostnameDomain)
820
821 dtxt, err := dspfr.Record()
822 if err != nil {
823 addf(&r.SPF.Errors, "Making SPF record for instructions: %s", err)
824 }
825 domainspf := fmt.Sprintf("%s IN TXT %s", domain.ASCII+".", mox.TXTStrings(dtxt))
826
827 // Check SPF record for sending host. ../rfc/7208:2263 ../rfc/7208:2287
828 hostspf := fmt.Sprintf(`%s IN TXT "v=spf1 a -all"`, mox.Conf.Static.HostnameDomain.ASCII+".")
829
830 addf(&r.SPF.Instructions, "Ensure DNS TXT records like the following exists:\n\n\t%s\n\t%s\n\nIf you have an existing mail setup, with other hosts also sending mail for you domain, you should add those IPs as well. You could replace \"-all\" with \"~all\" to treat mail sent from unlisted IPs as \"softfail\", or with \"?all\" for \"neutral\".", domainspf, hostspf)
831 }()
832
833 // DKIM
834 // todo: add warnings if we have Transports with submission? admin should ensure DKIM records exist. we cannot easily check if they actually exist though. and should we first check the transport can be used from this domain (or an account that has this domain?). also see SPF.
835 wg.Add(1)
836 go func() {
837 defer logPanic(ctx)
838 defer wg.Done()
839
840 var missing []string
841 var haveEd25519 bool
842 for sel, selc := range domConf.DKIM.Selectors {
843 if _, ok := selc.Key.(ed25519.PrivateKey); ok {
844 haveEd25519 = true
845 }
846
847 _, record, txt, err := dkim.Lookup(ctx, resolver, selc.Domain, domain)
848 if err != nil {
849 missing = append(missing, sel)
850 if errors.Is(err, dkim.ErrNoRecord) {
851 addf(&r.DKIM.Errors, "No DKIM DNS record for selector %q.", sel)
852 } else if errors.Is(err, dkim.ErrSyntax) {
853 addf(&r.DKIM.Errors, "Parsing DKIM DNS record for selector %q: %s", sel, err)
854 } else {
855 addf(&r.DKIM.Errors, "Fetching DKIM record for selector %q: %s", sel, err)
856 }
857 }
858 if txt != "" {
859 r.DKIM.Records = append(r.DKIM.Records, DKIMRecord{sel, txt, record})
860 pubKey := selc.Key.Public()
861 var pk []byte
862 switch k := pubKey.(type) {
863 case *rsa.PublicKey:
864 var err error
865 pk, err = x509.MarshalPKIXPublicKey(k)
866 if err != nil {
867 addf(&r.DKIM.Errors, "Marshal public key for %q to compare against DNS: %s", sel, err)
868 continue
869 }
870 case ed25519.PublicKey:
871 pk = []byte(k)
872 default:
873 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", pubKey)
874 continue
875 }
876
877 if record != nil && !bytes.Equal(record.Pubkey, pk) {
878 addf(&r.DKIM.Errors, "For selector %q, the public key in DKIM DNS TXT record does not match with configured private key.", sel)
879 missing = append(missing, sel)
880 }
881 }
882 }
883 if len(domConf.DKIM.Selectors) == 0 {
884 addf(&r.DKIM.Errors, "No DKIM configuration, add a key to the configuration file, and instructions for DNS records will appear here.")
885 } else if !haveEd25519 {
886 addf(&r.DKIM.Warnings, "Consider adding an ed25519 key: the keys are smaller, the cryptography faster and more modern.")
887 }
888 instr := ""
889 for _, sel := range missing {
890 dkimr := dkim.Record{
891 Version: "DKIM1",
892 Hashes: []string{"sha256"},
893 PublicKey: domConf.DKIM.Selectors[sel].Key.Public(),
894 }
895 switch dkimr.PublicKey.(type) {
896 case *rsa.PublicKey:
897 case ed25519.PublicKey:
898 dkimr.Key = "ed25519"
899 default:
900 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", dkimr.PublicKey)
901 }
902 txt, err := dkimr.Record()
903 if err != nil {
904 addf(&r.DKIM.Errors, "Making DKIM record for instructions: %s", err)
905 continue
906 }
907 instr += fmt.Sprintf("\n\t%s._domainkey IN TXT %s\n", sel, mox.TXTStrings(txt))
908 }
909 if instr != "" {
910 instr = "Ensure the following DNS record(s) exists, so mail servers receiving emails from this domain can verify the signatures in the mail headers:\n" + instr
911 addf(&r.DKIM.Instructions, "%s", instr)
912 }
913 }()
914
915 // DMARC
916 wg.Add(1)
917 go func() {
918 defer logPanic(ctx)
919 defer wg.Done()
920
921 _, dmarcDomain, record, txt, err := dmarc.Lookup(ctx, resolver, domain)
922 if err != nil {
923 addf(&r.DMARC.Errors, "Looking up DMARC record: %s", err)
924 } else if record == nil {
925 addf(&r.DMARC.Errors, "No DMARC record")
926 }
927 r.DMARC.Domain = dmarcDomain.Name()
928 r.DMARC.TXT = txt
929 if record != nil {
930 r.DMARC.Record = &DMARCRecord{*record}
931 }
932 if record != nil && record.Policy == "none" {
933 addf(&r.DMARC.Warnings, "DMARC policy is in test mode (p=none), do not forget to change to p=reject or p=quarantine after test period has been completed.")
934 }
935 if record != nil && record.SubdomainPolicy == "none" {
936 addf(&r.DMARC.Warnings, "DMARC subdomain policy is in test mode (sp=none), do not forget to change to sp=reject or sp=quarantine after test period has been completed.")
937 }
938 if record != nil && len(record.AggregateReportAddresses) == 0 {
939 addf(&r.DMARC.Warnings, "It is recommended you specify you would like aggregate reports about delivery success in the DMARC record, see instructions.")
940 }
941
942 dmarcr := dmarc.DefaultRecord
943 dmarcr.Policy = "reject"
944
945 var extInstr string
946 if domConf.DMARC != nil {
947 // If the domain is in a different Organizational Domain, the receiving domain
948 // needs a special DNS record to opt-in to receiving reports. We check for that
949 // record.
950 // ../rfc/7489:1541
951 orgDom := publicsuffix.Lookup(ctx, domain)
952 destOrgDom := publicsuffix.Lookup(ctx, domConf.DMARC.DNSDomain)
953 if orgDom != destOrgDom {
954 accepts, status, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, resolver, domain, domConf.DMARC.DNSDomain)
955 if status != dmarc.StatusNone {
956 addf(&r.DMARC.Errors, "Checking if external destination accepts reports: %s", err)
957 } else if !accepts {
958 addf(&r.DMARC.Errors, "External destination does not accept reports (%s)", err)
959 }
960 extInstr = fmt.Sprintf("Ensure a DNS TXT record exists in the domain of the destination address to opt-in to receiving reports from this domain:\n\n\t%s._report._dmarc.%s. IN TXT \"v=DMARC1;\"\n\n", domain.ASCII, domConf.DMARC.DNSDomain.ASCII)
961 }
962
963 uri := url.URL{
964 Scheme: "mailto",
965 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
966 }
967 dmarcr.AggregateReportAddresses = []dmarc.URI{
968 {Address: uri.String(), MaxSize: 10, Unit: "m"},
969 }
970 } else {
971 addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file.`)
972 }
973 instr := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_dmarc IN TXT %s\n\nYou can start with testing mode by replacing p=reject with p=none. You can also request for the policy to be applied to a percentage of emails instead of all, by adding pct=X, with X between 0 and 100. Keep in mind that receiving mail servers will apply some anti-spam assessment regardless of the policy and whether it is applied to the message. The ruf= part requests daily aggregate reports to be sent to the specified address, which is automatically configured and reports automatically analyzed.", mox.TXTStrings(dmarcr.String()))
974 addf(&r.DMARC.Instructions, instr)
975 if extInstr != "" {
976 addf(&r.DMARC.Instructions, extInstr)
977 }
978 }()
979
980 // TLSRPT
981 wg.Add(1)
982 go func() {
983 defer logPanic(ctx)
984 defer wg.Done()
985
986 record, txt, err := tlsrpt.Lookup(ctx, resolver, domain)
987 if err != nil {
988 addf(&r.TLSRPT.Errors, "Looking up TLSRPT record: %s", err)
989 }
990 r.TLSRPT.TXT = txt
991 if record != nil {
992 r.TLSRPT.Record = &TLSRPTRecord{*record}
993 }
994
995 instr := `TLSRPT is an opt-in mechanism to request feedback about TLS connectivity from remote SMTP servers when they connect to us. It allows detecting delivery problems and unwanted downgrades to plaintext SMTP connections. With TLSRPT you configure an email address to which reports should be sent. Remote SMTP servers will send a report once a day with the number of successful connections, and the number of failed connections including details that should help debugging/resolving any issues.`
996 if domConf.TLSRPT != nil {
997 // TLSRPT does not require validation of reporting addresses outside the domain.
998 // ../rfc/8460:1463
999 uri := url.URL{
1000 Scheme: "mailto",
1001 Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
1002 }
1003 uristr := uri.String()
1004 uristr = strings.ReplaceAll(uristr, ",", "%2C")
1005 uristr = strings.ReplaceAll(uristr, "!", "%21")
1006 uristr = strings.ReplaceAll(uristr, ";", "%3B")
1007 tlsrptr := &tlsrpt.Record{
1008 Version: "TLSRPTv1",
1009 RUAs: [][]string{{uristr}},
1010 }
1011 instr += fmt.Sprintf(`
1012
1013Ensure a DNS TXT record like the following exists:
1014
1015 _smtp._tls IN TXT %s
1016`, mox.TXTStrings(tlsrptr.String()))
1017 } else {
1018 addf(&r.TLSRPT.Errors, `Configure a TLSRPT destination in domain in config file.`)
1019 }
1020 addf(&r.TLSRPT.Instructions, instr)
1021 }()
1022
1023 // MTA-STS
1024 wg.Add(1)
1025 go func() {
1026 defer logPanic(ctx)
1027 defer wg.Done()
1028
1029 record, txt, cnames, err := mtasts.LookupRecord(ctx, resolver, domain)
1030 if err != nil {
1031 addf(&r.MTASTS.Errors, "Looking up MTA-STS record: %s", err)
1032 }
1033 if cnames != nil {
1034 r.MTASTS.CNAMEs = cnames
1035 } else {
1036 r.MTASTS.CNAMEs = []string{}
1037 }
1038 r.MTASTS.TXT = txt
1039 if record != nil {
1040 r.MTASTS.Record = &MTASTSRecord{*record}
1041 }
1042
1043 policy, text, err := mtasts.FetchPolicy(ctx, domain)
1044 if err != nil {
1045 addf(&r.MTASTS.Errors, "Fetching MTA-STS policy: %s", err)
1046 } else if policy.Mode == mtasts.ModeNone {
1047 addf(&r.MTASTS.Warnings, "MTA-STS policy is present, but does not require TLS.")
1048 } else if policy.Mode == mtasts.ModeTesting {
1049 addf(&r.MTASTS.Warnings, "MTA-STS policy is in testing mode, do not forget to change to mode enforce after testing period.")
1050 }
1051 r.MTASTS.PolicyText = text
1052 r.MTASTS.Policy = policy
1053 if policy != nil && policy.Mode != mtasts.ModeNone {
1054 if !policy.Matches(mox.Conf.Static.HostnameDomain) {
1055 addf(&r.MTASTS.Warnings, "Configured hostname is missing from policy MX list.")
1056 }
1057 if policy.MaxAgeSeconds <= 24*3600 {
1058 addf(&r.MTASTS.Warnings, "Policy has a MaxAge of less than 1 day. For stable configurations, the recommended period is in weeks.")
1059 }
1060
1061 mxl, _ := resolver.LookupMX(ctx, domain.ASCII+".")
1062 // We do not check for errors, the MX check will complain about mx errors, we assume we will get the same error here.
1063 mxs := map[dns.Domain]struct{}{}
1064 for _, mx := range mxl {
1065 d, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
1066 if err != nil {
1067 addf(&r.MTASTS.Warnings, "MX record %q is invalid: %s", mx.Host, err)
1068 continue
1069 }
1070 mxs[d] = struct{}{}
1071 }
1072 for mx := range mxs {
1073 if !policy.Matches(mx) {
1074 addf(&r.MTASTS.Warnings, "MX record %q does not match MTA-STS policy MX list.", mx)
1075 }
1076 }
1077 for _, mx := range policy.MX {
1078 if mx.Wildcard {
1079 continue
1080 }
1081 if _, ok := mxs[mx.Domain]; !ok {
1082 addf(&r.MTASTS.Warnings, "MX %q in MTA-STS policy is not in MX record.", mx)
1083 }
1084 }
1085 }
1086
1087 intro := `MTA-STS is an opt-in mechanism to signal to remote SMTP servers which MX records are valid and that they must use the STARTTLS command and verify the TLS connection. Email servers should already be using STARTTLS to protect communication, but active attackers can, and have in the past, removed the indication of support for the optional STARTTLS support from SMTP sessions, or added additional MX records in DNS responses. MTA-STS protects against compromised DNS and compromised plaintext SMTP sessions, but not against compromised internet PKI infrastructure. If an attacker controls a certificate authority, and is willing to use it, MTA-STS does not prevent an attack. MTA-STS does not protect against attackers on first contact with a domain. Only on subsequent contacts, with MTA-STS policies in the cache, can attacks can be detected.
1088
1089After enabling MTA-STS for this domain, remote SMTP servers may still deliver in plain text, without TLS-protection. MTA-STS is an opt-in mechanism, not all servers support it yet.
1090
1091You can opt-in to MTA-STS by creating a DNS record, _mta-sts.<domain>, and serving a policy at https://mta-sts.<domain>/.well-known/mta-sts.txt. Mox will serve the policy, you must create the DNS records.
1092
1093You can start with a policy in "testing" mode. Remote SMTP servers will apply the MTA-STS policy, but not abort delivery in case of failure. Instead, you will receive a report if you have TLSRPT configured. By starting in testing mode for a representative period, verifying all mail can be deliverd, you can safely switch to "enforce" mode. While in enforce mode, plaintext deliveries to mox are refused.
1094
1095The _mta-sts DNS TXT record has an "id" field. The id serves as a version of the policy. A policy specifies the mode: none, testing, enforce. For "none", no TLS is required. A policy has a "max age", indicating how long the policy can be cached. Allowing the policy to be cached for a long time provides stronger counter measures to active attackers, but reduces configuration change agility. After enabling "enforce" mode, remote SMTP servers may and will cache your policy for as long as "max age" was configured. Keep this in mind when enabling/disabling MTA-STS. To disable MTA-STS after having it enabled, publish a new record with mode "none" until all past policy expiration times have passed.
1096
1097When enabling MTA-STS, or updating a policy, always update the policy first (through a configuration change and reload/restart), and the DNS record second.
1098`
1099 addf(&r.MTASTS.Instructions, intro)
1100
1101 addf(&r.MTASTS.Instructions, `Enable a policy through the configuration file. For new deployments, it is best to start with mode "testing" while enabling TLSRPT. Start with a short "max_age", so updates to your policy are picked up quickly. When confidence in the deployment is high enough, switch to "enforce" mode and a longer "max age". A max age in the order of weeks is recommended. If you foresee a change to your setup in the future, requiring different policies or MX records, you may want to dial back the "max age" ahead of time, similar to how you would handle TTL's in DNS record updates.`)
1102
1103 host := fmt.Sprintf("Ensure DNS CNAME/A/AAAA records exist that resolve mta-sts.%s to this mail server. For example:\n\n\t%s IN CNAME %s\n\n", domain.ASCII, "mta-sts."+domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1104 addf(&r.MTASTS.Instructions, host)
1105
1106 mtastsr := mtasts.Record{
1107 Version: "STSv1",
1108 ID: time.Now().Format("20060102T150405"),
1109 }
1110 dns := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_mta-sts IN TXT %s\n\nConfigure the ID in the configuration file, it must be of the form [a-zA-Z0-9]{1,31}. It represents the version of the policy. For each policy change, you must change the ID to a new unique value. You could use a timestamp like 20220621T123000. When this field exists, an SMTP server will fetch a policy at https://mta-sts.%s/.well-known/mta-sts.txt. This policy is served by mox.", mox.TXTStrings(mtastsr.String()), domain.Name())
1111 addf(&r.MTASTS.Instructions, dns)
1112 }()
1113
1114 // SRVConf
1115 wg.Add(1)
1116 go func() {
1117 defer logPanic(ctx)
1118 defer wg.Done()
1119
1120 type srvReq struct {
1121 name string
1122 port uint16
1123 host string
1124 srvs []*net.SRV
1125 err error
1126 }
1127
1128 // We'll assume if any submissions is configured, it is public. Same for imap. And
1129 // if not, that there is a plain option.
1130 var submissions, imaps bool
1131 for _, l := range mox.Conf.Static.Listeners {
1132 if l.TLS != nil && l.Submissions.Enabled {
1133 submissions = true
1134 }
1135 if l.TLS != nil && l.IMAPS.Enabled {
1136 imaps = true
1137 }
1138 }
1139 srvhost := func(ok bool) string {
1140 if ok {
1141 return mox.Conf.Static.HostnameDomain.ASCII + "."
1142 }
1143 return "."
1144 }
1145 var reqs = []srvReq{
1146 {name: "_submissions", port: 465, host: srvhost(submissions)},
1147 {name: "_submission", port: 587, host: srvhost(!submissions)},
1148 {name: "_imaps", port: 993, host: srvhost(imaps)},
1149 {name: "_imap", port: 143, host: srvhost(!imaps)},
1150 {name: "_pop3", port: 110, host: "."},
1151 {name: "_pop3s", port: 995, host: "."},
1152 }
1153 var srvwg sync.WaitGroup
1154 srvwg.Add(len(reqs))
1155 for i := range reqs {
1156 go func(i int) {
1157 defer srvwg.Done()
1158 _, reqs[i].srvs, reqs[i].err = resolver.LookupSRV(ctx, reqs[i].name[1:], "tcp", domain.ASCII+".")
1159 }(i)
1160 }
1161 srvwg.Wait()
1162
1163 instr := "Ensure DNS records like the following exist:\n\n"
1164 r.SRVConf.SRVs = map[string][]*net.SRV{}
1165 for _, req := range reqs {
1166 name := req.name + "_.tcp." + domain.ASCII
1167 instr += fmt.Sprintf("\t%s._tcp.%-*s IN SRV 0 1 %d %s\n", req.name, len("_submissions")-len(req.name)+len(domain.ASCII+"."), domain.ASCII+".", req.port, req.host)
1168 r.SRVConf.SRVs[req.name] = req.srvs
1169 if err != nil {
1170 addf(&r.SRVConf.Errors, "Looking up SRV record %q: %s", name, err)
1171 } else if len(req.srvs) == 0 {
1172 addf(&r.SRVConf.Errors, "Missing SRV record %q", name)
1173 } else if len(req.srvs) != 1 || req.srvs[0].Target != req.host || req.srvs[0].Port != req.port {
1174 addf(&r.SRVConf.Errors, "Unexpected SRV record(s) for %q", name)
1175 }
1176 }
1177 addf(&r.SRVConf.Instructions, instr)
1178 }()
1179
1180 // Autoconf
1181 wg.Add(1)
1182 go func() {
1183 defer logPanic(ctx)
1184 defer wg.Done()
1185
1186 addf(&r.Autoconf.Instructions, "Ensure a DNS CNAME record like the following exists:\n\n\tautoconfig.%s IN CNAME %s\n\nNote: the trailing dot is relevant, it makes the host name absolute instead of relative to the domain name.", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1187
1188 host := "autoconfig." + domain.ASCII + "."
1189 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, host)
1190 if err != nil {
1191 addf(&r.Autoconf.Errors, "Looking up autoconfig host: %s", err)
1192 return
1193 }
1194
1195 r.Autoconf.IPs = ips
1196 if !isUnspecifiedNAT {
1197 if len(ourIPs) == 0 {
1198 addf(&r.Autoconf.Errors, "Autoconfig does not point to one of our IPs.")
1199 } else if len(notOurIPs) > 0 {
1200 addf(&r.Autoconf.Errors, "Autoconfig points to some IPs that are not ours: %v", notOurIPs)
1201 }
1202 }
1203
1204 checkTLS(&r.Autoconf.Errors, "autoconfig."+domain.ASCII, ips, "443")
1205 }()
1206
1207 // Autodiscover
1208 wg.Add(1)
1209 go func() {
1210 defer logPanic(ctx)
1211 defer wg.Done()
1212
1213 addf(&r.Autodiscover.Instructions, "Ensure DNS records like the following exist:\n\n\t_autodiscover._tcp.%s IN SRV 0 1 443 autoconfig.%s\n\tautoconfig.%s IN CNAME %s\n\nNote: the trailing dots are relevant, it makes the host names absolute instead of relative to the domain name.", domain.ASCII+".", domain.ASCII+".", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1214
1215 _, srvs, err := resolver.LookupSRV(ctx, "autodiscover", "tcp", domain.ASCII+".")
1216 if err != nil {
1217 addf(&r.Autodiscover.Errors, "Looking up SRV record %q: %s", "autodiscover", err)
1218 return
1219 }
1220 match := false
1221 for _, srv := range srvs {
1222 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autodiscover.Errors, srv.Target)
1223 if err != nil {
1224 addf(&r.Autodiscover.Errors, "Looking up target %q from SRV record: %s", srv.Target, err)
1225 continue
1226 }
1227 if srv.Port != 443 {
1228 continue
1229 }
1230 match = true
1231 r.Autodiscover.Records = append(r.Autodiscover.Records, AutodiscoverSRV{*srv, ips})
1232 if !isUnspecifiedNAT {
1233 if len(ourIPs) == 0 {
1234 addf(&r.Autodiscover.Errors, "SRV target %q does not point to our IPs.", srv.Target)
1235 } else if len(notOurIPs) > 0 {
1236 addf(&r.Autodiscover.Errors, "SRV target %q points to some IPs that are not ours: %v", srv.Target, notOurIPs)
1237 }
1238 }
1239
1240 checkTLS(&r.Autodiscover.Errors, strings.TrimSuffix(srv.Target, "."), ips, "443")
1241 }
1242 if !match {
1243 addf(&r.Autodiscover.Errors, "No SRV record for port 443 for https.")
1244 }
1245 }()
1246
1247 wg.Wait()
1248 return
1249}
1250
1251// Domains returns all configured domain names, in UTF-8 for IDNA domains.
1252func (Admin) Domains(ctx context.Context) []dns.Domain {
1253 l := []dns.Domain{}
1254 for _, s := range mox.Conf.Domains() {
1255 d, _ := dns.ParseDomain(s)
1256 l = append(l, d)
1257 }
1258 return l
1259}
1260
1261// Domain returns the dns domain for a (potentially unicode as IDNA) domain name.
1262func (Admin) Domain(ctx context.Context, domain string) dns.Domain {
1263 d, err := dns.ParseDomain(domain)
1264 xcheckuserf(ctx, err, "parse domain")
1265 _, ok := mox.Conf.Domain(d)
1266 if !ok {
1267 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1268 }
1269 return d
1270}
1271
1272// DomainLocalparts returns the encoded localparts and accounts configured in domain.
1273func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string) {
1274 d, err := dns.ParseDomain(domain)
1275 xcheckuserf(ctx, err, "parsing domain")
1276 _, ok := mox.Conf.Domain(d)
1277 if !ok {
1278 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1279 }
1280 return mox.Conf.DomainLocalparts(d)
1281}
1282
1283// Accounts returns the names of all configured accounts.
1284func (Admin) Accounts(ctx context.Context) []string {
1285 l := mox.Conf.Accounts()
1286 sort.Slice(l, func(i, j int) bool {
1287 return l[i] < l[j]
1288 })
1289 return l
1290}
1291
1292// Account returns the parsed configuration of an account.
1293func (Admin) Account(ctx context.Context, account string) map[string]any {
1294 ac, ok := mox.Conf.Account(account)
1295 if !ok {
1296 xcheckuserf(ctx, errors.New("no such account"), "looking up account")
1297 }
1298
1299 // todo: should change sherpa to understand config.Account directly, with its anonymous structs.
1300 buf, err := json.Marshal(ac)
1301 xcheckf(ctx, err, "marshal to json")
1302 r := map[string]any{}
1303 err = json.Unmarshal(buf, &r)
1304 xcheckf(ctx, err, "unmarshal from json")
1305
1306 return r
1307}
1308
1309// ConfigFiles returns the paths and contents of the static and dynamic configuration files.
1310func (Admin) ConfigFiles(ctx context.Context) (staticPath, dynamicPath, static, dynamic string) {
1311 buf0, err := os.ReadFile(mox.ConfigStaticPath)
1312 xcheckf(ctx, err, "read static config file")
1313 buf1, err := os.ReadFile(mox.ConfigDynamicPath)
1314 xcheckf(ctx, err, "read dynamic config file")
1315 return mox.ConfigStaticPath, mox.ConfigDynamicPath, string(buf0), string(buf1)
1316}
1317
1318// MTASTSPolicies returns all mtasts policies from the cache.
1319func (Admin) MTASTSPolicies(ctx context.Context) (records []mtastsdb.PolicyRecord) {
1320 records, err := mtastsdb.PolicyRecords(ctx)
1321 xcheckf(ctx, err, "fetching mtasts policies from database")
1322 return records
1323}
1324
1325// TLSReports returns TLS reports overlapping with period start/end, for the given
1326// domain (or all domains if empty). The reports are sorted first by period end
1327// (most recent first), then by domain.
1328func (Admin) TLSReports(ctx context.Context, start, end time.Time, domain string) (reports []tlsrptdb.TLSReportRecord) {
1329 records, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, domain)
1330 xcheckf(ctx, err, "fetching tlsrpt report records from database")
1331 sort.Slice(records, func(i, j int) bool {
1332 iend := records[i].Report.DateRange.End
1333 jend := records[j].Report.DateRange.End
1334 if iend == jend {
1335 return records[i].Domain < records[j].Domain
1336 }
1337 return iend.After(jend)
1338 })
1339 return records
1340}
1341
1342// TLSReportID returns a single TLS report.
1343func (Admin) TLSReportID(ctx context.Context, domain string, reportID int64) tlsrptdb.TLSReportRecord {
1344 record, err := tlsrptdb.RecordID(ctx, reportID)
1345 if err == nil && record.Domain != domain {
1346 err = bstore.ErrAbsent
1347 }
1348 if err == bstore.ErrAbsent {
1349 xcheckuserf(ctx, err, "fetching tls report from database")
1350 }
1351 xcheckf(ctx, err, "fetching tls report from database")
1352 return record
1353}
1354
1355// TLSRPTSummary presents TLS reporting statistics for a single domain
1356// over a period.
1357type TLSRPTSummary struct {
1358 Domain string
1359 Success int64
1360 Failure int64
1361 ResultTypeCounts map[tlsrpt.ResultType]int
1362}
1363
1364// TLSRPTSummaries returns a summary of received TLS reports overlapping with
1365// period start/end for one or all domains (when domain is empty).
1366// The returned summaries are ordered by domain name.
1367func (Admin) TLSRPTSummaries(ctx context.Context, start, end time.Time, domain string) (domainSummaries []TLSRPTSummary) {
1368 reports, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, domain)
1369 xcheckf(ctx, err, "fetching tlsrpt reports from database")
1370 summaries := map[string]TLSRPTSummary{}
1371 for _, r := range reports {
1372 sum := summaries[r.Domain]
1373 sum.Domain = r.Domain
1374 for _, result := range r.Report.Policies {
1375 sum.Success += result.Summary.TotalSuccessfulSessionCount
1376 sum.Failure += result.Summary.TotalFailureSessionCount
1377 for _, details := range result.FailureDetails {
1378 if sum.ResultTypeCounts == nil {
1379 sum.ResultTypeCounts = map[tlsrpt.ResultType]int{}
1380 }
1381 sum.ResultTypeCounts[details.ResultType]++
1382 }
1383 }
1384 summaries[r.Domain] = sum
1385 }
1386 sums := make([]TLSRPTSummary, 0, len(summaries))
1387 for _, sum := range summaries {
1388 sums = append(sums, sum)
1389 }
1390 sort.Slice(sums, func(i, j int) bool {
1391 return sums[i].Domain < sums[j].Domain
1392 })
1393 return sums
1394}
1395
1396// DMARCReports returns DMARC reports overlapping with period start/end, for the
1397// given domain (or all domains if empty). The reports are sorted first by period
1398// end (most recent first), then by domain.
1399func (Admin) DMARCReports(ctx context.Context, start, end time.Time, domain string) (reports []dmarcdb.DomainFeedback) {
1400 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1401 xcheckf(ctx, err, "fetching dmarc reports from database")
1402 sort.Slice(reports, func(i, j int) bool {
1403 iend := reports[i].ReportMetadata.DateRange.End
1404 jend := reports[j].ReportMetadata.DateRange.End
1405 if iend == jend {
1406 return reports[i].Domain < reports[j].Domain
1407 }
1408 return iend > jend
1409 })
1410 return reports
1411}
1412
1413// DMARCReportID returns a single DMARC report.
1414func (Admin) DMARCReportID(ctx context.Context, domain string, reportID int64) (report dmarcdb.DomainFeedback) {
1415 report, err := dmarcdb.RecordID(ctx, reportID)
1416 if err == nil && report.Domain != domain {
1417 err = bstore.ErrAbsent
1418 }
1419 if err == bstore.ErrAbsent {
1420 xcheckuserf(ctx, err, "fetching dmarc report from database")
1421 }
1422 xcheckf(ctx, err, "fetching dmarc report from database")
1423 return report
1424}
1425
1426// DMARCSummary presents DMARC aggregate reporting statistics for a single domain
1427// over a period.
1428type DMARCSummary struct {
1429 Domain string
1430 Total int
1431 DispositionNone int
1432 DispositionQuarantine int
1433 DispositionReject int
1434 DKIMFail int
1435 SPFFail int
1436 PolicyOverrides map[dmarcrpt.PolicyOverride]int
1437}
1438
1439// DMARCSummaries returns a summary of received DMARC reports overlapping with
1440// period start/end for one or all domains (when domain is empty).
1441// The returned summaries are ordered by domain name.
1442func (Admin) DMARCSummaries(ctx context.Context, start, end time.Time, domain string) (domainSummaries []DMARCSummary) {
1443 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1444 xcheckf(ctx, err, "fetching dmarc reports from database")
1445 summaries := map[string]DMARCSummary{}
1446 for _, r := range reports {
1447 sum := summaries[r.Domain]
1448 sum.Domain = r.Domain
1449 for _, record := range r.Records {
1450 n := record.Row.Count
1451
1452 sum.Total += n
1453
1454 switch record.Row.PolicyEvaluated.Disposition {
1455 case dmarcrpt.DispositionNone:
1456 sum.DispositionNone += n
1457 case dmarcrpt.DispositionQuarantine:
1458 sum.DispositionQuarantine += n
1459 case dmarcrpt.DispositionReject:
1460 sum.DispositionReject += n
1461 }
1462
1463 if record.Row.PolicyEvaluated.DKIM == dmarcrpt.DMARCFail {
1464 sum.DKIMFail += n
1465 }
1466 if record.Row.PolicyEvaluated.SPF == dmarcrpt.DMARCFail {
1467 sum.SPFFail += n
1468 }
1469
1470 for _, reason := range record.Row.PolicyEvaluated.Reasons {
1471 if sum.PolicyOverrides == nil {
1472 sum.PolicyOverrides = map[dmarcrpt.PolicyOverride]int{}
1473 }
1474 sum.PolicyOverrides[reason.Type] += n
1475 }
1476 }
1477 summaries[r.Domain] = sum
1478 }
1479 sums := make([]DMARCSummary, 0, len(summaries))
1480 for _, sum := range summaries {
1481 sums = append(sums, sum)
1482 }
1483 sort.Slice(sums, func(i, j int) bool {
1484 return sums[i].Domain < sums[j].Domain
1485 })
1486 return sums
1487}
1488
1489// Reverse is the result of a reverse lookup.
1490type Reverse struct {
1491 Hostnames []string
1492
1493 // In the future, we can add a iprev-validated host name, and possibly the IPs of the host names.
1494}
1495
1496// LookupIP does a reverse lookup of ip.
1497func (Admin) LookupIP(ctx context.Context, ip string) Reverse {
1498 resolver := dns.StrictResolver{Pkg: "webadmin"}
1499 names, err := resolver.LookupAddr(ctx, ip)
1500 xcheckuserf(ctx, err, "looking up ip")
1501 return Reverse{names}
1502}
1503
1504// DNSBLStatus returns the IPs from which outgoing connections may be made and
1505// their current status in DNSBLs that are configured. The IPs are typically the
1506// configured listen IPs, or otherwise IPs on the machines network interfaces, with
1507// internal/private IPs removed.
1508//
1509// The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and
1510// anything else is an error string, e.g. "fail: ..." or "temperror: ...".
1511func (Admin) DNSBLStatus(ctx context.Context) map[string]map[string]string {
1512 resolver := dns.StrictResolver{Pkg: "check"}
1513 return dnsblsStatus(ctx, resolver)
1514}
1515
1516func dnsblsStatus(ctx context.Context, resolver dns.Resolver) map[string]map[string]string {
1517 // todo: check health before using dnsbl?
1518 var dnsbls []dns.Domain
1519 if l, ok := mox.Conf.Static.Listeners["public"]; ok {
1520 for _, dnsbl := range l.SMTP.DNSBLs {
1521 zone, err := dns.ParseDomain(dnsbl)
1522 xcheckf(ctx, err, "parse dnsbl zone")
1523 dnsbls = append(dnsbls, zone)
1524 }
1525 }
1526
1527 r := map[string]map[string]string{}
1528 for _, ip := range xsendingIPs(ctx) {
1529 if ip.IsLoopback() || ip.IsPrivate() {
1530 continue
1531 }
1532 ipstr := ip.String()
1533 r[ipstr] = map[string]string{}
1534 for _, zone := range dnsbls {
1535 status, expl, err := dnsbl.Lookup(ctx, resolver, zone, ip)
1536 result := string(status)
1537 if err != nil {
1538 result += ": " + err.Error()
1539 }
1540 if expl != "" {
1541 result += ": " + expl
1542 }
1543 r[ipstr][zone.LogString()] = result
1544 }
1545 }
1546 return r
1547}
1548
1549// DomainRecords returns lines describing DNS records that should exist for the
1550// configured domain.
1551func (Admin) DomainRecords(ctx context.Context, domain string) []string {
1552 d, err := dns.ParseDomain(domain)
1553 xcheckuserf(ctx, err, "parsing domain")
1554 dc, ok := mox.Conf.Domain(d)
1555 if !ok {
1556 xcheckuserf(ctx, errors.New("unknown domain"), "lookup domain")
1557 }
1558 records, err := mox.DomainRecords(dc, d)
1559 xcheckf(ctx, err, "dns records")
1560 return records
1561}
1562
1563// DomainAdd adds a new domain and reloads the configuration.
1564func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart string) {
1565 d, err := dns.ParseDomain(domain)
1566 xcheckuserf(ctx, err, "parsing domain")
1567
1568 err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(localpart))
1569 xcheckf(ctx, err, "adding domain")
1570}
1571
1572// DomainRemove removes an existing domain and reloads the configuration.
1573func (Admin) DomainRemove(ctx context.Context, domain string) {
1574 d, err := dns.ParseDomain(domain)
1575 xcheckuserf(ctx, err, "parsing domain")
1576
1577 err = mox.DomainRemove(ctx, d)
1578 xcheckf(ctx, err, "removing domain")
1579}
1580
1581// AccountAdd adds existing a new account, with an initial email address, and
1582// reloads the configuration.
1583func (Admin) AccountAdd(ctx context.Context, accountName, address string) {
1584 err := mox.AccountAdd(ctx, accountName, address)
1585 xcheckf(ctx, err, "adding account")
1586}
1587
1588// AccountRemove removes an existing account and reloads the configuration.
1589func (Admin) AccountRemove(ctx context.Context, accountName string) {
1590 err := mox.AccountRemove(ctx, accountName)
1591 xcheckf(ctx, err, "removing account")
1592}
1593
1594// AddressAdd adds a new address to the account, which must already exist.
1595func (Admin) AddressAdd(ctx context.Context, address, accountName string) {
1596 err := mox.AddressAdd(ctx, address, accountName)
1597 xcheckf(ctx, err, "adding address")
1598}
1599
1600// AddressRemove removes an existing address.
1601func (Admin) AddressRemove(ctx context.Context, address string) {
1602 err := mox.AddressRemove(ctx, address)
1603 xcheckf(ctx, err, "removing address")
1604}
1605
1606// SetPassword saves a new password for an account, invalidating the previous password.
1607// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
1608// Password must be at least 8 characters.
1609func (Admin) SetPassword(ctx context.Context, accountName, password string) {
1610 if len(password) < 8 {
1611 panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
1612 }
1613 acc, err := store.OpenAccount(accountName)
1614 xcheckf(ctx, err, "open account")
1615 defer func() {
1616 err := acc.Close()
1617 xlog.Check(err, "closing account")
1618 }()
1619 err = acc.SetPassword(password)
1620 xcheckf(ctx, err, "setting password")
1621}
1622
1623// SetAccountLimits set new limits on outgoing messages for an account.
1624func (Admin) SetAccountLimits(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int) {
1625 err := mox.AccountLimitsSave(ctx, accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay)
1626 xcheckf(ctx, err, "saving account limits")
1627}
1628
1629// ClientConfigsDomain returns configurations for email clients, IMAP and
1630// Submission (SMTP) for the domain.
1631func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs {
1632 d, err := dns.ParseDomain(domain)
1633 xcheckuserf(ctx, err, "parsing domain")
1634
1635 cc, err := mox.ClientConfigsDomain(d)
1636 xcheckf(ctx, err, "client config for domain")
1637 return cc
1638}
1639
1640// QueueList returns the messages currently in the outgoing queue.
1641func (Admin) QueueList(ctx context.Context) []queue.Msg {
1642 l, err := queue.List(ctx)
1643 xcheckf(ctx, err, "listing messages in queue")
1644 return l
1645}
1646
1647// QueueSize returns the number of messages currently in the outgoing queue.
1648func (Admin) QueueSize(ctx context.Context) int {
1649 n, err := queue.Count(ctx)
1650 xcheckf(ctx, err, "listing messages in queue")
1651 return n
1652}
1653
1654// QueueKick initiates delivery of a message from the queue and sets the transport
1655// to use for delivery.
1656func (Admin) QueueKick(ctx context.Context, id int64, transport string) {
1657 n, err := queue.Kick(ctx, id, "", "", &transport)
1658 if err == nil && n == 0 {
1659 err = errors.New("message not found")
1660 }
1661 xcheckf(ctx, err, "kick message in queue")
1662}
1663
1664// QueueDrop removes a message from the queue.
1665func (Admin) QueueDrop(ctx context.Context, id int64) {
1666 n, err := queue.Drop(ctx, id, "", "")
1667 if err == nil && n == 0 {
1668 err = errors.New("message not found")
1669 }
1670 xcheckf(ctx, err, "drop message from queue")
1671}
1672
1673// LogLevels returns the current log levels.
1674func (Admin) LogLevels(ctx context.Context) map[string]string {
1675 m := map[string]string{}
1676 for pkg, level := range mox.Conf.LogLevels() {
1677 m[pkg] = level.String()
1678 }
1679 return m
1680}
1681
1682// LogLevelSet sets a log level for a package.
1683func (Admin) LogLevelSet(ctx context.Context, pkg string, levelStr string) {
1684 level, ok := mlog.Levels[levelStr]
1685 if !ok {
1686 xcheckuserf(ctx, errors.New("unknown"), "lookup level")
1687 }
1688 mox.Conf.LogLevelSet(pkg, level)
1689}
1690
1691// LogLevelRemove removes a log level for a package, which cannot be the empty string.
1692func (Admin) LogLevelRemove(ctx context.Context, pkg string) {
1693 mox.Conf.LogLevelRemove(pkg)
1694}
1695
1696// CheckUpdatesEnabled returns whether checking for updates is enabled.
1697func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
1698 return mox.Conf.Static.CheckUpdates
1699}
1700
1701// WebserverConfig is the combination of WebDomainRedirects and WebHandlers
1702// from the domains.conf configuration file.
1703type WebserverConfig struct {
1704 WebDNSDomainRedirects [][2]dns.Domain // From server to frontend.
1705 WebDomainRedirects [][2]string // From frontend to server, it's not convenient to create dns.Domain in the frontend.
1706 WebHandlers []config.WebHandler
1707}
1708
1709// WebserverConfig returns the current webserver config
1710func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
1711 conf = webserverConfig()
1712 conf.WebDomainRedirects = nil
1713 return conf
1714}
1715
1716func webserverConfig() WebserverConfig {
1717 r, l := mox.Conf.WebServer()
1718 x := make([][2]dns.Domain, 0, len(r))
1719 xs := make([][2]string, 0, len(r))
1720 for k, v := range r {
1721 x = append(x, [2]dns.Domain{k, v})
1722 xs = append(xs, [2]string{k.Name(), v.Name()})
1723 }
1724 sort.Slice(x, func(i, j int) bool {
1725 return x[i][0].ASCII < x[j][0].ASCII
1726 })
1727 sort.Slice(xs, func(i, j int) bool {
1728 return xs[i][0] < xs[j][0]
1729 })
1730 return WebserverConfig{x, xs, l}
1731}
1732
1733// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
1734// the current config, an error is returned.
1735func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf WebserverConfig) (savedConf WebserverConfig) {
1736 current := webserverConfig()
1737 webhandlersEqual := func() bool {
1738 if len(current.WebHandlers) != len(oldConf.WebHandlers) {
1739 return false
1740 }
1741 for i, wh := range current.WebHandlers {
1742 if !wh.Equal(oldConf.WebHandlers[i]) {
1743 return false
1744 }
1745 }
1746 return true
1747 }
1748 if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
1749 xcheckuserf(ctx, errors.New("config has changed"), "comparing old/current config")
1750 }
1751
1752 // Convert to map, check that there are no duplicates here. The canonicalized
1753 // dns.Domain are checked again for uniqueness when parsing the config before
1754 // storing.
1755 domainRedirects := map[string]string{}
1756 for _, x := range newConf.WebDomainRedirects {
1757 if _, ok := domainRedirects[x[0]]; ok {
1758 xcheckuserf(ctx, errors.New("already present"), "checking redirect %s", x[0])
1759 }
1760 domainRedirects[x[0]] = x[1]
1761 }
1762
1763 err := mox.WebserverConfigSet(ctx, domainRedirects, newConf.WebHandlers)
1764 xcheckf(ctx, err, "saving webserver config")
1765
1766 savedConf = webserverConfig()
1767 savedConf.WebDomainRedirects = nil
1768 return savedConf
1769}
1770
1771// Transports returns the configured transports, for sending email.
1772func (Admin) Transports(ctx context.Context) map[string]config.Transport {
1773 return mox.Conf.Static.Transports
1774}
1775