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,
33 "golang.org/x/crypto/bcrypt"
35 "github.com/mjl-/bstore"
36 "github.com/mjl-/sherpa"
37 "github.com/mjl-/sherpadoc"
38 "github.com/mjl-/sherpaprom"
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"
62var xlog = mlog.New("webadmin")
64//go:embed adminapi.json
65var adminapiJSON []byte
70var adminDoc = mustParseAPI("admin", adminapiJSON)
72var adminSherpaHandler http.Handler
74func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
75 err := json.Unmarshal(buf, &doc)
77 xlog.Fatalx("parsing api docs", err, mlog.Field("api", api))
83 collector, err := sherpaprom.NewCollector("moxadmin", nil)
85 xlog.Fatalx("creating sherpa prometheus collector", err)
88 adminSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Admin{}, &adminDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
90 xlog.Fatalx("sherpa handler", err)
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.
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 {
104 lastSuccessHash, lastSuccessAuth string
107// started when we start serving. not at package init time, because we don't want
108// to make goroutines that early.
109func ManageAuthCache() {
112 authCache.lastSuccessHash = ""
113 authCache.lastSuccessAuth = ""
115 time.Sleep(15 * time.Minute)
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)
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)
132 authResult := "error"
134 var addr *net.TCPAddr
136 metrics.AuthenticationInc("webadmin", "httpbasic", authResult)
137 if authResult == "ok" && addr != nil {
138 mox.LimiterFailedAuth.Reset(addr.IP, start)
144 addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr)
146 log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr))
147 } else if addr != nil {
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)
156 authHdr := r.Header.Get("Authorization")
157 if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" {
158 return respondAuthFail()
160 buf, err := os.ReadFile(passwordfile)
162 log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile))
163 return respondAuthFail()
165 passwordhash := strings.TrimSpace(string(buf))
167 defer authCache.Unlock()
168 if passwordhash != "" && passwordhash == authCache.lastSuccessHash && authHdr != "" && authCache.lastSuccessAuth == authHdr {
172 auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic "))
174 return respondAuthFail()
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()
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()
186 authCache.lastSuccessHash = passwordhash
187 authCache.lastSuccessAuth = authHdr
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.
199 if lw, ok := w.(interface{ AddField(f mlog.Pair) }); ok {
200 lw.AddField(mlog.Field("authadmin", true))
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")
213 _, _ = w.Write(adminHTML)
217 adminSherpaHandler.ServeHTTP(w, r.WithContext(ctx))
220func xcheckf(ctx context.Context, err error, format string, args ...any) {
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})
230func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
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})
243 Instructions []string
246type TLSCheckResult struct {
250type IPRevCheckResult struct {
251 Hostname dns.Domain // This hostname, IPs must resolve back to this.
252 IPNames map[string][]string // IP to names.
262type MXCheckResult struct {
267type SPFRecord struct {
271type SPFCheckResult struct {
273 DomainRecord *SPFRecord
275 HostRecord *SPFRecord
279type DKIMCheckResult struct {
284type DKIMRecord struct {
290type DMARCRecord struct {
294type DMARCCheckResult struct {
301type TLSRPTRecord struct {
305type TLSRPTCheckResult struct {
311type MTASTSRecord struct {
314type MTASTSCheckResult struct {
319 Policy *mtasts.Policy
323type SRVConfCheckResult struct {
324 SRVs map[string][]*net.SRV // Service (e.g. "_imaps") to records.
328type AutoconfCheckResult struct {
333type AutodiscoverSRV struct {
338type AutodiscoverCheckResult struct {
339 Records []AutodiscoverSRV
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 {
348 IPRev IPRevCheckResult
353 DMARC DMARCCheckResult
354 TLSRPT TLSRPTCheckResult
355 MTASTS MTASTSCheckResult
356 SRVConf SRVConfCheckResult
357 Autoconf AutoconfCheckResult
358 Autodiscover AutodiscoverCheckResult
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) {
367 log := xlog.WithContext(ctx)
368 log.Error("recover from panic", mlog.Field("panic", x))
370 metrics.PanicInc(metrics.Webadmin)
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")
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")
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.
392 resolver := dns.StrictResolver{Pkg: "check"}
393 dialer := &net.Dialer{Timeout: 10 * time.Second}
394 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
396 return checkDomain(nctx, resolver, dialer, domainName)
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")
403 domConf, ok := mox.Conf.Domain(domain)
405 panic(&sherpa.Error{Code: "user:notFound", Message: "domain not found"})
408 listenIPs := xlistenIPs(ctx, true)
409 isListenIP := func(ip net.IP) bool {
410 for _, lip := range listenIPs {
418 addf := func(l *[]string, format string, args ...any) {
419 *l = append(*l, fmt.Sprintf(format, args...))
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)
426 addf(errors, "Looking up %q: %s", host, err)
427 return nil, nil, nil, err
429 for _, addr := range addrs {
430 ip := net.ParseIP(addr)
432 addf(errors, "Bad IP %q", addr)
435 ips = append(ips, ip.String())
437 ourIPs = append(ourIPs, ip)
439 notOurIPs = append(notOurIPs, ip)
442 return ips, ourIPs, notOurIPs, nil
445 checkTLS := func(errors *[]string, host string, ips []string, port string) {
451 RootCAs: mox.Conf.Static.TLS.CertPool,
454 for _, ip := range ips {
455 conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, port))
457 addf(errors, "TLS connection to hostname %q, IP %q: %s", host, ip, err)
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 {
472 isUnspecifiedNAT = true
475 if len(l.NATIPs) > 0 {
480 var wg sync.WaitGroup
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+".")
493 addf(&r.IPRev.Errors, "Looking up IPs for hostname: %s", err)
496 gatherMoreIPs := func(publicIPs []net.IP) {
498 for _, ip := range publicIPs {
499 for _, xip := range ips {
504 ips = append(ips, ip)
508 gatherMoreIPs(mox.Conf.Static.SpecifiedSMTPListenIPs)
510 for _, l := range mox.Conf.Static.Listeners {
515 for _, ip := range l.NATIPs {
516 natips = append(natips, net.ParseIP(ip))
518 gatherMoreIPs(natips)
520 hostIPs[mox.Conf.Static.HostnameDomain] = ips
522 iplist := func(ips []net.IP) string {
524 for _, ip := range ips {
525 ipstrs = append(ipstrs, ip.String())
527 return strings.Join(ipstrs, ", ")
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),
535 // If we have a socks transport, also check its host and IP.
536 for tname, t := range mox.Conf.Static.Transports {
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)
550 results := make(chan result)
552 for host, ips := range hostIPs {
553 for _, ip := range ips {
558 addrs, err := resolver.LookupAddr(ctx, s)
559 results <- result{host, s, addrs, err}
563 r.IPRev.IPNames = map[string][]string{}
564 for i := 0; i < n; i++ {
566 host, addrs, ip, err := lr.Host, lr.Addrs, lr.IP, lr.Err
568 addf(&r.IPRev.Errors, "Looking up reverse name for %s of %s: %v", ip, host, err)
572 addf(&r.IPRev.Errors, "Expected exactly 1 name for %s of %s, got %d (%v)", ip, host, len(addrs), addrs)
575 for i, a := range addrs {
576 a = strings.TrimRight(a, ".")
578 ad, err := dns.ParseDomain(a)
580 addf(&r.IPRev.Errors, "Parsing reverse name %q for %s: %v", a, ip, err)
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)
589 r.IPRev.IPNames[ip] = addrs
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 {
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())
609 mxs, err := resolver.LookupMX(ctx, domain.ASCII+".")
611 addf(&r.MX.Errors, "Looking up MX records for %s: %s", domain, err)
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}
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.`)
621 for i, mx := range mxs {
622 ips, ourIPs, notOurIPs, err := lookupIPs(&r.MX.Errors, mx.Host)
624 addf(&r.MX.Errors, "Looking up IPs for mx host %q: %s", mx.Host, err)
626 r.MX.Records[i].IPs = ips
627 if isUnspecifiedNAT {
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)
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+"."),
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.
649 // MTA-STS, autoconfig, autodiscover are checked in their sections.
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"))
663 end := time.Now().Add(10 * time.Second)
664 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
666 err = conn.SetDeadline(end)
667 xlog.WithContext(ctx).Check(err, "setting deadline")
669 br := bufio.NewReader(conn)
670 _, err = br.ReadString('\n')
672 return fmt.Errorf("reading SMTP banner from remote: %s", err)
674 if _, err := fmt.Fprintf(conn, "EHLO moxtest\r\n"); err != nil {
675 return fmt.Errorf("writing SMTP EHLO to remote: %s", err)
678 line, err := br.ReadString('\n')
680 return fmt.Errorf("reading SMTP EHLO response from remote: %s", err)
682 if strings.HasPrefix(line, "250-") {
685 if strings.HasPrefix(line, "250 ") {
688 return fmt.Errorf("unexpected response to SMTP EHLO from remote: %q", strings.TrimSuffix(line, "\r\n"))
690 if _, err := fmt.Fprintf(conn, "STARTTLS\r\n"); err != nil {
691 return fmt.Errorf("writing SMTP STARTTLS to remote: %s", err)
693 line, err := br.ReadString('\n')
695 return fmt.Errorf("reading response to SMTP STARTTLS from remote: %s", err)
697 if !strings.HasPrefix(line, "220 ") {
698 return fmt.Errorf("SMTP STARTTLS response from remote not 220 OK: %q", strings.TrimSuffix(line, "\r\n"))
700 config := &tls.Config{
702 RootCAs: mox.Conf.Static.TLS.CertPool,
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)
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+".")
720 if len(mxs) == 1 && mxs[0].Host == "." {
723 for _, mx := range mxs {
724 ips, _, _, err := lookupIPs(&r.MX.Errors, mx.Host)
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)
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.
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)
752 addf(&r.SPF.Errors, "Looking up %s SPF record: %s", kind, err)
754 var xrecord *SPFRecord
756 xrecord = &SPFRecord{*record}
763 checkSPFIP := func(ip net.IP) {
768 spfr.Directives = append(spfr.Directives, spf.Directive{Mechanism: mechanism, IP: 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"},
782 status, mechanism, expl, err := spf.Evaluate(ctx, record, resolver, args)
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)
790 for _, l := range mox.Conf.Static.Listeners {
791 if !l.SMTP.Enabled || l.IPsNATed {
795 if len(l.NATIPs) > 0 {
798 for _, ipstr := range ips {
799 ip := net.ParseIP(ipstr)
803 for _, t := range mox.Conf.Static.Transports {
805 for _, ip := range t.Socks.IPs {
811 spfr.Directives = append(spfr.Directives, spf.Directive{Qualifier: "-", Mechanism: "all"})
812 return txt, xrecord, spfr
815 // Check SPF record for domain.
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)
821 dtxt, err := dspfr.Record()
823 addf(&r.SPF.Errors, "Making SPF record for instructions: %s", err)
825 domainspf := fmt.Sprintf("%s IN TXT %s", domain.ASCII+".", mox.TXTStrings(dtxt))
828 hostspf := fmt.Sprintf(`%s IN TXT "v=spf1 a -all"`, mox.Conf.Static.HostnameDomain.ASCII+".")
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)
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.
842 for sel, selc := range domConf.DKIM.Selectors {
843 if _, ok := selc.Key.(ed25519.PrivateKey); ok {
847 _, record, txt, err := dkim.Lookup(ctx, resolver, selc.Domain, domain)
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)
855 addf(&r.DKIM.Errors, "Fetching DKIM record for selector %q: %s", sel, err)
859 r.DKIM.Records = append(r.DKIM.Records, DKIMRecord{sel, txt, record})
860 pubKey := selc.Key.Public()
862 switch k := pubKey.(type) {
865 pk, err = x509.MarshalPKIXPublicKey(k)
867 addf(&r.DKIM.Errors, "Marshal public key for %q to compare against DNS: %s", sel, err)
870 case ed25519.PublicKey:
873 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", pubKey)
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)
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.")
889 for _, sel := range missing {
890 dkimr := dkim.Record{
892 Hashes: []string{"sha256"},
893 PublicKey: domConf.DKIM.Selectors[sel].Key.Public(),
895 switch dkimr.PublicKey.(type) {
897 case ed25519.PublicKey:
898 dkimr.Key = "ed25519"
900 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", dkimr.PublicKey)
902 txt, err := dkimr.Record()
904 addf(&r.DKIM.Errors, "Making DKIM record for instructions: %s", err)
907 instr += fmt.Sprintf("\n\t%s._domainkey IN TXT %s\n", sel, mox.TXTStrings(txt))
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)
921 _, dmarcDomain, record, txt, err := dmarc.Lookup(ctx, resolver, domain)
923 addf(&r.DMARC.Errors, "Looking up DMARC record: %s", err)
924 } else if record == nil {
925 addf(&r.DMARC.Errors, "No DMARC record")
927 r.DMARC.Domain = dmarcDomain.Name()
930 r.DMARC.Record = &DMARCRecord{*record}
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.")
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.")
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.")
942 dmarcr := dmarc.DefaultRecord
943 dmarcr.Policy = "reject"
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
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)
958 addf(&r.DMARC.Errors, "External destination does not accept reports (%s)", err)
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)
965 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
967 dmarcr.AggregateReportAddresses = []dmarc.URI{
968 {Address: uri.String(), MaxSize: 10, Unit: "m"},
971 addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file.`)
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)
976 addf(&r.DMARC.Instructions, extInstr)
986 record, txt, err := tlsrpt.Lookup(ctx, resolver, domain)
988 addf(&r.TLSRPT.Errors, "Looking up TLSRPT record: %s", err)
992 r.TLSRPT.Record = &TLSRPTRecord{*record}
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.
1001 Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
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}},
1011 instr += fmt.Sprintf(`
1013Ensure a DNS TXT record like the following exists:
1015 _smtp._tls IN TXT %s
1016`, mox.TXTStrings(tlsrptr.String()))
1018 addf(&r.TLSRPT.Errors, `Configure a TLSRPT destination in domain in config file.`)
1020 addf(&r.TLSRPT.Instructions, instr)
1029 record, txt, cnames, err := mtasts.LookupRecord(ctx, resolver, domain)
1031 addf(&r.MTASTS.Errors, "Looking up MTA-STS record: %s", err)
1034 r.MTASTS.CNAMEs = cnames
1036 r.MTASTS.CNAMEs = []string{}
1040 r.MTASTS.Record = &MTASTSRecord{*record}
1043 policy, text, err := mtasts.FetchPolicy(ctx, domain)
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.")
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.")
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.")
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, "."))
1067 addf(&r.MTASTS.Warnings, "MX record %q is invalid: %s", mx.Host, err)
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)
1077 for _, mx := range policy.MX {
1081 if _, ok := mxs[mx.Domain]; !ok {
1082 addf(&r.MTASTS.Warnings, "MX %q in MTA-STS policy is not in MX record.", mx)
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.
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.
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.
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.
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.
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.
1099 addf(&r.MTASTS.Instructions, intro)
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.`)
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)
1106 mtastsr := mtasts.Record{
1108 ID: time.Now().Format("20060102T150405"),
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)
1120 type srvReq struct {
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 {
1135 if l.TLS != nil && l.IMAPS.Enabled {
1139 srvhost := func(ok bool) string {
1141 return mox.Conf.Static.HostnameDomain.ASCII + "."
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: "."},
1153 var srvwg sync.WaitGroup
1154 srvwg.Add(len(reqs))
1155 for i := range reqs {
1158 _, reqs[i].srvs, reqs[i].err = resolver.LookupSRV(ctx, reqs[i].name[1:], "tcp", domain.ASCII+".")
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
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)
1177 addf(&r.SRVConf.Instructions, instr)
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+".")
1188 host := "autoconfig." + domain.ASCII + "."
1189 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, host)
1191 addf(&r.Autoconf.Errors, "Looking up autoconfig host: %s", err)
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)
1204 checkTLS(&r.Autoconf.Errors, "autoconfig."+domain.ASCII, ips, "443")
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+".")
1215 _, srvs, err := resolver.LookupSRV(ctx, "autodiscover", "tcp", domain.ASCII+".")
1217 addf(&r.Autodiscover.Errors, "Looking up SRV record %q: %s", "autodiscover", err)
1221 for _, srv := range srvs {
1222 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autodiscover.Errors, srv.Target)
1224 addf(&r.Autodiscover.Errors, "Looking up target %q from SRV record: %s", srv.Target, err)
1227 if srv.Port != 443 {
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)
1240 checkTLS(&r.Autodiscover.Errors, strings.TrimSuffix(srv.Target, "."), ips, "443")
1243 addf(&r.Autodiscover.Errors, "No SRV record for port 443 for https.")
1251// Domains returns all configured domain names, in UTF-8 for IDNA domains.
1252func (Admin) Domains(ctx context.Context) []dns.Domain {
1254 for _, s := range mox.Conf.Domains() {
1255 d, _ := dns.ParseDomain(s)
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)
1267 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
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)
1278 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1280 return mox.Conf.DomainLocalparts(d)
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 {
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)
1296 xcheckuserf(ctx, errors.New("no such account"), "looking up account")
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")
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)
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")
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
1335 return records[i].Domain < records[j].Domain
1337 return iend.After(jend)
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
1348 if err == bstore.ErrAbsent {
1349 xcheckuserf(ctx, err, "fetching tls report from database")
1351 xcheckf(ctx, err, "fetching tls report from database")
1355// TLSRPTSummary presents TLS reporting statistics for a single domain
1357type TLSRPTSummary struct {
1361 ResultTypeCounts map[tlsrpt.ResultType]int
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{}
1381 sum.ResultTypeCounts[details.ResultType]++
1384 summaries[r.Domain] = sum
1386 sums := make([]TLSRPTSummary, 0, len(summaries))
1387 for _, sum := range summaries {
1388 sums = append(sums, sum)
1390 sort.Slice(sums, func(i, j int) bool {
1391 return sums[i].Domain < sums[j].Domain
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
1406 return reports[i].Domain < reports[j].Domain
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
1419 if err == bstore.ErrAbsent {
1420 xcheckuserf(ctx, err, "fetching dmarc report from database")
1422 xcheckf(ctx, err, "fetching dmarc report from database")
1426// DMARCSummary presents DMARC aggregate reporting statistics for a single domain
1428type DMARCSummary struct {
1432 DispositionQuarantine int
1433 DispositionReject int
1436 PolicyOverrides map[dmarcrpt.PolicyOverride]int
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
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
1463 if record.Row.PolicyEvaluated.DKIM == dmarcrpt.DMARCFail {
1466 if record.Row.PolicyEvaluated.SPF == dmarcrpt.DMARCFail {
1470 for _, reason := range record.Row.PolicyEvaluated.Reasons {
1471 if sum.PolicyOverrides == nil {
1472 sum.PolicyOverrides = map[dmarcrpt.PolicyOverride]int{}
1474 sum.PolicyOverrides[reason.Type] += n
1477 summaries[r.Domain] = sum
1479 sums := make([]DMARCSummary, 0, len(summaries))
1480 for _, sum := range summaries {
1481 sums = append(sums, sum)
1483 sort.Slice(sums, func(i, j int) bool {
1484 return sums[i].Domain < sums[j].Domain
1489// Reverse is the result of a reverse lookup.
1490type Reverse struct {
1493 // In the future, we can add a iprev-validated host name, and possibly the IPs of the host names.
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}
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.
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)
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)
1527 r := map[string]map[string]string{}
1528 for _, ip := range xsendingIPs(ctx) {
1529 if ip.IsLoopback() || ip.IsPrivate() {
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)
1538 result += ": " + err.Error()
1541 result += ": " + expl
1543 r[ipstr][zone.LogString()] = result
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)
1556 xcheckuserf(ctx, errors.New("unknown domain"), "lookup domain")
1558 records, err := mox.DomainRecords(dc, d)
1559 xcheckf(ctx, err, "dns records")
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")
1568 err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(localpart))
1569 xcheckf(ctx, err, "adding domain")
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")
1577 err = mox.DomainRemove(ctx, d)
1578 xcheckf(ctx, err, "removing domain")
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")
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")
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")
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")
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"})
1613 acc, err := store.OpenAccount(accountName)
1614 xcheckf(ctx, err, "open account")
1617 xlog.Check(err, "closing account")
1619 err = acc.SetPassword(password)
1620 xcheckf(ctx, err, "setting password")
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")
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")
1635 cc, err := mox.ClientConfigsDomain(d)
1636 xcheckf(ctx, err, "client config for domain")
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")
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")
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")
1661 xcheckf(ctx, err, "kick message in queue")
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")
1670 xcheckf(ctx, err, "drop message from queue")
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()
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]
1686 xcheckuserf(ctx, errors.New("unknown"), "lookup level")
1688 mox.Conf.LogLevelSet(pkg, level)
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)
1696// CheckUpdatesEnabled returns whether checking for updates is enabled.
1697func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
1698 return mox.Conf.Static.CheckUpdates
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
1709// WebserverConfig returns the current webserver config
1710func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
1711 conf = webserverConfig()
1712 conf.WebDomainRedirects = nil
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()})
1724 sort.Slice(x, func(i, j int) bool {
1725 return x[i][0].ASCII < x[j][0].ASCII
1727 sort.Slice(xs, func(i, j int) bool {
1728 return xs[i][0] < xs[j][0]
1730 return WebserverConfig{x, xs, l}
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) {
1741 for i, wh := range current.WebHandlers {
1742 if !wh.Equal(oldConf.WebHandlers[i]) {
1748 if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
1749 xcheckuserf(ctx, errors.New("config has changed"), "comparing old/current config")
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
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])
1760 domainRedirects[x[0]] = x[1]
1763 err := mox.WebserverConfigSet(ctx, domainRedirects, newConf.WebHandlers)
1764 xcheckf(ctx, err, "saving webserver config")
1766 savedConf = webserverConfig()
1767 savedConf.WebDomainRedirects = nil
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