From 2b97c21f99d44155118aa2b9d77ad34eb11e7c64 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 23 Sep 2023 12:05:40 +0200 Subject: [PATCH] make setting up apple mail clients easier by providing .mobileconfig device management profiles including showing a qr code to easily get the file on iphones. the profile is currently in the "account" page. idea by x8x in issue #65 --- README.md | 9 +- go.mod | 1 + go.sum | 2 + http/autoconf.go | 227 ++++----- http/mobileconfig.go | 200 ++++++++ http/web.go | 12 +- main.go | 2 +- mox-/admin.go | 116 ++++- vendor/modules.txt | 5 + vendor/rsc.io/qr/LICENSE | 27 ++ vendor/rsc.io/qr/README.md | 3 + vendor/rsc.io/qr/coding/qr.go | 815 ++++++++++++++++++++++++++++++++ vendor/rsc.io/qr/gf256/gf256.go | 241 ++++++++++ vendor/rsc.io/qr/png.go | 400 ++++++++++++++++ vendor/rsc.io/qr/qr.go | 116 +++++ webaccount/account.go | 2 +- webaccount/account.html | 13 +- webadmin/admin.go | 9 +- webadmin/admin.html | 8 +- webadmin/adminapi.json | 16 +- 20 files changed, 2076 insertions(+), 148 deletions(-) create mode 100644 http/mobileconfig.go create mode 100644 vendor/rsc.io/qr/LICENSE create mode 100644 vendor/rsc.io/qr/README.md create mode 100644 vendor/rsc.io/qr/coding/qr.go create mode 100644 vendor/rsc.io/qr/gf256/gf256.go create mode 100644 vendor/rsc.io/qr/png.go create mode 100644 vendor/rsc.io/qr/qr.go diff --git a/README.md b/README.md index bd799f2..a8fcbe7 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ See Quickstart below to get started. (instructions to create DNS records, configure SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing accounts/domains, and modifying the configuration file. -- Autodiscovery (with SRV records, Microsoft-style and Thunderbird-style) for - easy account setup (though not many clients support it). +- Autodiscovery (with SRV records, Microsoft-style, Thunderbird-style, and Apple + device management profiles) for easy account setup (though client support is + limited). - Webmail for reading/sending email from the browser. - Webserver with serving static files and forwarding requests (reverse proxy), so port 443 can also be used to serve websites. @@ -39,8 +40,8 @@ See Quickstart below to get started. testing/developing, including pedantic mode. Mox is available under the MIT-license and was created by Mechiel Lukkien, -mechiel@ueber.net. Mox includes the Public Suffix List by Mozilla, under Mozilla -Public License, v2.0. +mechiel@ueber.net. Mox includes BSD-3-claused code from the Go Authors, and the +Public Suffix List by Mozilla under Mozilla Public License, v2.0. # Download diff --git a/go.mod b/go.mod index b668294..a8fee3f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb golang.org/x/net v0.15.0 golang.org/x/text v0.13.0 + rsc.io/qr v0.2.0 ) require ( diff --git a/go.sum b/go.sum index 1ec93d1..f69ec83 100644 --- a/go.sum +++ b/go.sum @@ -510,5 +510,7 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/http/autoconf.go b/http/autoconf.go index 7e2ebc1..c1a27a6 100644 --- a/http/autoconf.go +++ b/http/autoconf.go @@ -4,11 +4,12 @@ import ( "encoding/xml" "fmt" "net/http" + "strings" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "rsc.io/qr" - "github.com/mjl-/mox/config" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/smtp" @@ -36,7 +37,9 @@ var ( // - Thunderbird will request an "autoconfig" xml file. // - Microsoft tools will request an "autodiscovery" xml file. // - In my tests on an internal domain, iOS mail only talks to Apple servers, then -// does not attempt autoconfiguration. Possibly due to them being private DNS names. +// does not attempt autoconfiguration. Possibly due to them being private DNS +// names. Apple software can be provisioned with "mobileconfig" profile files, +// which users can download after logging in. // // DNS records seem optional, but autoconfig. and autodiscover. // (both CNAME or A) are useful, and so is SRV _autodiscovery._tcp. 0 0 443 @@ -67,13 +70,31 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) { return } - if _, ok := mox.Conf.Domain(addr.Domain); !ok { - http.Error(w, "400 - bad request - unknown domain", http.StatusBadRequest) + socketType := func(tlsMode mox.TLSMode) (string, error) { + switch tlsMode { + case mox.TLSModeImmediate: + return "SSL", nil + case mox.TLSModeSTARTTLS: + return "STARTTLS", nil + case mox.TLSModeNone: + return "plain", nil + default: + return "", fmt.Errorf("unknown tls mode %v", tlsMode) + } + } + + var imapTLS, submissionTLS string + config, err := mox.ClientConfigDomain(addr.Domain) + if err == nil { + imapTLS, err = socketType(config.IMAP.TLSMode) + } + if err == nil { + submissionTLS, err = socketType(config.Submission.TLSMode) + } + if err != nil { + http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest) return } - addrDom = addr.Domain.Name() - - hostname := mox.Conf.Static.HostnameDomain // Thunderbird doesn't seem to allow U-labels, always return ASCII names. var resp autoconfigResponse @@ -83,64 +104,24 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) { resp.EmailProvider.DisplayName = email resp.EmailProvider.DisplayShortName = addr.Domain.ASCII - var imapPort int - var imapSocket string - for _, l := range mox.Conf.Static.Listeners { - if l.IMAPS.Enabled { - imapSocket = "SSL" - imapPort = config.Port(l.IMAPS.Port, 993) - } else if l.IMAP.Enabled { - if l.TLS != nil && imapSocket != "SSL" { - imapSocket = "STARTTLS" - imapPort = config.Port(l.IMAP.Port, 143) - } else if imapSocket == "" { - imapSocket = "plain" - imapPort = config.Port(l.IMAP.Port, 143) - } - } - } - if imapPort == 0 { - log.Error("autoconfig: no imap configured?") - } - // todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then. resp.EmailProvider.IncomingServer.Type = "imap" - resp.EmailProvider.IncomingServer.Hostname = hostname.ASCII - resp.EmailProvider.IncomingServer.Port = imapPort - resp.EmailProvider.IncomingServer.SocketType = imapSocket + resp.EmailProvider.IncomingServer.Hostname = config.IMAP.Host.ASCII + resp.EmailProvider.IncomingServer.Port = config.IMAP.Port + resp.EmailProvider.IncomingServer.SocketType = imapTLS resp.EmailProvider.IncomingServer.Username = email resp.EmailProvider.IncomingServer.Authentication = "password-encrypted" - var smtpPort int - var smtpSocket string - for _, l := range mox.Conf.Static.Listeners { - if l.Submissions.Enabled { - smtpSocket = "SSL" - smtpPort = config.Port(l.Submissions.Port, 465) - } else if l.Submission.Enabled { - if l.TLS != nil && smtpSocket != "SSL" { - smtpSocket = "STARTTLS" - smtpPort = config.Port(l.Submission.Port, 587) - } else if smtpSocket == "" { - smtpSocket = "plain" - smtpPort = config.Port(l.Submission.Port, 587) - } - } - } - if smtpPort == 0 { - log.Error("autoconfig: no smtp submission configured?") - } - resp.EmailProvider.OutgoingServer.Type = "smtp" - resp.EmailProvider.OutgoingServer.Hostname = hostname.ASCII - resp.EmailProvider.OutgoingServer.Port = smtpPort - resp.EmailProvider.OutgoingServer.SocketType = smtpSocket + resp.EmailProvider.OutgoingServer.Hostname = config.Submission.Host.ASCII + resp.EmailProvider.OutgoingServer.Port = config.Submission.Port + resp.EmailProvider.OutgoingServer.SocketType = submissionTLS resp.EmailProvider.OutgoingServer.Username = email resp.EmailProvider.OutgoingServer.Authentication = "password-encrypted" // todo: should we put the email address in the URL? - resp.ClientConfigUpdate.URL = fmt.Sprintf("https://%s/mail/config-v1.1.xml", hostname.ASCII) + resp.ClientConfigUpdate.URL = fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", addr.Domain.ASCII) w.Header().Set("Content-Type", "application/xml; charset=utf-8") enc := xml.NewEncoder(w) @@ -188,13 +169,33 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { return } - if _, ok := mox.Conf.Domain(addr.Domain); !ok { - http.Error(w, "400 - bad request - unknown domain", http.StatusBadRequest) + // tlsmode returns the "ssl" and "encryption" fields. + tlsmode := func(tlsMode mox.TLSMode) (string, string, error) { + switch tlsMode { + case mox.TLSModeImmediate: + return "on", "TLS", nil + case mox.TLSModeSTARTTLS: + return "on", "", nil + case mox.TLSModeNone: + return "off", "", nil + default: + return "", "", fmt.Errorf("unknown tls mode %v", tlsMode) + } + } + + var imapSSL, imapEncryption string + var submissionSSL, submissionEncryption string + config, err := mox.ClientConfigDomain(addr.Domain) + if err == nil { + imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode) + } + if err == nil { + submissionSSL, submissionEncryption, err = tlsmode(config.Submission.TLSMode) + } + if err != nil { + http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest) return } - addrDom = addr.Domain.Name() - - hostname := mox.Conf.Static.HostnameDomain // The docs are generated and fragmented in many tiny pages, hard to follow. // High-level starting point, https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/78530279-d042-4eb0-a1f4-03b18143cd19 @@ -205,47 +206,6 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { // use. See // https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726 - var imapPort int - imapSSL := "off" - var imapEncryption string - - var smtpPort int - smtpSSL := "off" - var smtpEncryption string - for _, l := range mox.Conf.Static.Listeners { - if l.IMAPS.Enabled { - imapPort = config.Port(l.IMAPS.Port, 993) - imapSSL = "on" - imapEncryption = "TLS" // Assuming this means direct TLS. - } else if l.IMAP.Enabled { - if l.TLS != nil && imapEncryption != "TLS" { - imapSSL = "on" - imapPort = config.Port(l.IMAP.Port, 143) - } else if imapSSL == "" { - imapPort = config.Port(l.IMAP.Port, 143) - } - } - - if l.Submissions.Enabled { - smtpPort = config.Port(l.Submissions.Port, 465) - smtpSSL = "on" - smtpEncryption = "TLS" // Assuming this means direct TLS. - } else if l.Submission.Enabled { - if l.TLS != nil && smtpEncryption != "TLS" { - smtpSSL = "on" - smtpPort = config.Port(l.Submission.Port, 587) - } else if smtpSSL == "" { - smtpPort = config.Port(l.Submission.Port, 587) - } - } - } - if imapPort == 0 { - log.Error("autoconfig: no smtp submission configured?") - } - if smtpPort == 0 { - log.Error("autoconfig: no imap configured?") - } - w.Header().Set("Content-Type", "application/xml; charset=utf-8") resp := autodiscoverResponse{} @@ -259,8 +219,8 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { Protocol: []autodiscoverProtocol{ { Type: "IMAP", - Server: hostname.ASCII, - Port: imapPort, + Server: config.IMAP.Host.ASCII, + Port: config.IMAP.Port, LoginName: req.Request.EmailAddress, SSL: imapSSL, Encryption: imapEncryption, @@ -269,11 +229,11 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) { }, { Type: "SMTP", - Server: hostname.ASCII, - Port: smtpPort, + Server: config.Submission.Host.ASCII, + Port: config.Submission.Port, LoginName: req.Request.EmailAddress, - SSL: smtpSSL, - Encryption: smtpEncryption, + SSL: submissionSSL, + Encryption: submissionEncryption, SPA: "off", // Override default "on", this is Microsofts proprietary authentication protocol. AuthRequired: "on", }, @@ -360,3 +320,58 @@ type autodiscoverProtocol struct { SPA string AuthRequired string } + +// Serve a .mobileconfig file. This endpoint is not a standard place where Apple +// devices look. We point to it from the account page. +func mobileconfigHandle(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed) + return + } + address := r.FormValue("address") + fullName := r.FormValue("name") + buf, err := MobileConfig(address, fullName) + if err != nil { + http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest) + return + } + h := w.Header() + filename := address + filename = strings.ReplaceAll(filename, ".", "-") + filename = strings.ReplaceAll(filename, "@", "-at-") + filename = "email-account-" + filename + ".mobileconfig" + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Write(buf) +} + +// Serve a png file with qrcode with the link to the .mobileconfig file, should be +// helpful for mobile devices. +func mobileconfigQRCodeHandle(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed) + return + } + if !strings.HasSuffix(r.URL.Path, ".qrcode.png") { + http.NotFound(w, r) + return + } + + // Compose URL, scheme and host are not set. + u := *r.URL + if r.TLS == nil { + u.Scheme = "http" + } else { + u.Scheme = "https" + } + u.Host = r.Host + u.Path = strings.TrimSuffix(u.Path, ".qrcode.png") + + code, err := qr.Encode(u.String(), qr.L) + if err != nil { + http.Error(w, "500 - internal server error - generating qr-code: "+err.Error(), http.StatusInternalServerError) + return + } + h := w.Header() + h.Set("Content-Type", "image/png") + w.Write(code.PNG()) +} diff --git a/http/mobileconfig.go b/http/mobileconfig.go new file mode 100644 index 0000000..94bfc33 --- /dev/null +++ b/http/mobileconfig.go @@ -0,0 +1,200 @@ +package http + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/xml" + "fmt" + "sort" + "strings" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/smtp" +) + +// Apple software isn't good at autoconfig/autodiscovery, but it can import a +// device management profile containing account settings. +// +// See https://developer.apple.com/documentation/devicemanagement/mail. +type deviceManagementProfile struct { + XMLName xml.Name `xml:"plist"` + Version string `xml:"version,attr"` + Dict dict `xml:"dict"` +} + +type array []dict + +type dict map[string]any + +// MarshalXML marshals as with multiple pairs of and a value of various types. +func (m dict) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + // The plist format isn't that easy to generate with Go's xml package, it's leaving + // out reasonable structure, instead just concatenating key/value pairs. Perhaps + // there is a better way? + + if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "dict"}}); err != nil { + return err + } + l := maps.Keys(m) + sort.Strings(l) + for _, k := range l { + tokens := []xml.Token{ + xml.StartElement{Name: xml.Name{Local: "key"}}, + xml.CharData([]byte(k)), + xml.EndElement{Name: xml.Name{Local: "key"}}, + } + for _, t := range tokens { + if err := e.EncodeToken(t); err != nil { + return err + } + } + tokens = nil + + switch v := m[k].(type) { + case string: + tokens = []xml.Token{ + xml.StartElement{Name: xml.Name{Local: "string"}}, + xml.CharData([]byte(v)), + xml.EndElement{Name: xml.Name{Local: "string"}}, + } + case int: + tokens = []xml.Token{ + xml.StartElement{Name: xml.Name{Local: "integer"}}, + xml.CharData([]byte(fmt.Sprintf("%d", v))), + xml.EndElement{Name: xml.Name{Local: "integer"}}, + } + case bool: + tag := "false" + if v { + tag = "true" + } + tokens = []xml.Token{ + xml.StartElement{Name: xml.Name{Local: tag}}, + xml.EndElement{Name: xml.Name{Local: tag}}, + } + case array: + if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil { + return err + } + for _, d := range v { + if err := d.MarshalXML(e, xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil { + return err + } + } + if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "array"}}); err != nil { + return err + } + default: + return fmt.Errorf("unexpected dict value of type %T", v) + } + for _, t := range tokens { + if err := e.EncodeToken(t); err != nil { + return err + } + } + } + if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "dict"}}); err != nil { + return err + } + return nil +} + +// MobileConfig returns a device profile for a macOS Mail email account. The file +// should have a .mobileconfig extension. Opening the file adds it to Profiles in +// System Preferences, where it can be installed. This profile does not contain a +// password because sending opaque files containing passwords around to users seems +// like bad security practice. +// +// The config is not signed, so users must ignore warnings about unsigned profiles. +func MobileConfig(address, fullName string) ([]byte, error) { + addr, err := smtp.ParseAddress(address) + if err != nil { + return nil, fmt.Errorf("parsing address: %v", err) + } + + config, err := mox.ClientConfigDomain(addr.Domain) + if err != nil { + return nil, fmt.Errorf("getting config for domain: %v", err) + } + + // Apple software wants identifiers... + t := strings.Split(addr.Domain.Name(), ".") + slices.Reverse(t) + reverseAddr := strings.Join(t, ".") + "." + addr.Localpart.String() + + // Apple software wants UUIDs... We generate them deterministically based on address + // and our code (through key, which we must change if code changes). + const key = "mox0" + uuid := func(prefix string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(prefix + "\n" + "\n" + address)) + sum := mac.Sum(nil) + uuid := fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + return uuid + } + + uuidConfig := uuid("config") + uuidAccount := uuid("account") + + // The "UseSSL" fields are underspecified in Apple's format. They say "If true, + // enables SSL for authentication on the incoming mail server.". I'm assuming they + // want to know if they should start immediately with a handshake, instead of + // starting out plain. There is no way to require STARTTLS though. You could even + // interpret their wording as this field enable authentication through client-side + // TLS certificates, given their "on the incoming mail server", instead of "of the + // incoming mail server". + + var w bytes.Buffer + p := deviceManagementProfile{ + Version: "1.0", + Dict: dict(map[string]any{ + "PayloadDisplayName": fmt.Sprintf("%s email account", address), + "PayloadIdentifier": reverseAddr + ".email", + "PayloadType": "Configuration", + "PayloadUUID": uuidConfig, + "PayloadVersion": 1, + "PayloadContent": array{ + dict(map[string]any{ + "EmailAccountDescription": address, + "EmailAccountName": fullName, + "EmailAccountType": "EmailTypeIMAP", + "EmailAddress": address, + "IncomingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing.. + "IncomingMailServerUsername": address, + "IncomingMailServerHostName": config.IMAP.Host.ASCII, + "IncomingMailServerPortNumber": config.IMAP.Port, + "IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate, + "OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing... + "OutgoingMailServerHostName": config.Submission.Host.ASCII, + "OutgoingMailServerPortNumber": config.Submission.Port, + "OutgoingMailServerUsername": address, + "OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate, + "OutgoingPasswordSameAsIncomingPassword": true, + "PayloadIdentifier": reverseAddr + ".email.account", + "PayloadType": "com.apple.mail.managed", + "PayloadUUID": uuidAccount, + "PayloadVersion": 1, + }), + }, + }), + } + if _, err := fmt.Fprint(&w, xml.Header); err != nil { + return nil, err + } + if _, err := fmt.Fprint(&w, "\n"); err != nil { + return nil, err + } + enc := xml.NewEncoder(&w) + enc.Indent("", "\t") + if err := enc.Encode(p); err != nil { + return nil, err + } + if _, err := fmt.Fprintln(&w); err != nil { + return nil, err + } + return w.Bytes(), nil +} diff --git a/http/web.go b/http/web.go index c1ceaef..5ee9579 100644 --- a/http/web.go +++ b/http/web.go @@ -617,11 +617,19 @@ func Listen() { port := config.Port(l.AutoconfigHTTPS.Port, 443) srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "autoconfig-https") autoconfigMatch := func(dom dns.Domain) bool { - // todo: may want to check this against the configured domains, could in theory be just a webserver. - return strings.HasPrefix(dom.ASCII, "autoconfig.") + // Thunderbird requests an autodiscovery URL at the email address domain name, so + // autoconfig prefix is optional. + if strings.HasPrefix(dom.ASCII, "autoconfig.") { + dom.ASCII = strings.TrimPrefix(dom.ASCII, "autoconfig.") + dom.Unicode = strings.TrimPrefix(dom.Unicode, "autoconfig.") + } + _, ok := mox.Conf.Domain(dom) + return ok } srv.Handle("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(http.HandlerFunc(autoconfHandle))) srv.Handle("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(http.HandlerFunc(autodiscoverHandle))) + srv.Handle("mobileconfig", autoconfigMatch, "/profile.mobileconfig", safeHeaders(http.HandlerFunc(mobileconfigHandle))) + srv.Handle("mobileconfigqrcodepng", autoconfigMatch, "/profile.mobileconfig.qrcode.png", safeHeaders(http.HandlerFunc(mobileconfigQRCodeHandle))) } if l.MTASTSHTTPS.Enabled { port := config.Port(l.MTASTSHTTPS.Port, 443) diff --git a/main.go b/main.go index 6f1b409..545779c 100644 --- a/main.go +++ b/main.go @@ -509,7 +509,7 @@ configured over otherwise secured connections, like a VPN. } func printClientConfig(d dns.Domain) { - cc, err := mox.ClientConfigDomain(d) + cc, err := mox.ClientConfigsDomain(d) xcheckf(err, "getting client config") fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note") for _, e := range cc.Entries { diff --git a/mox-/admin.go b/mox-/admin.go index 4b3953c..86b72e8 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -17,6 +17,8 @@ import ( "strings" "time" + "golang.org/x/exp/maps" + "github.com/mjl-/mox/config" "github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dmarc" @@ -590,8 +592,7 @@ func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) { return records, nil } -// AccountAdd adds an account and an initial address and reloads the -// configuration. +// AccountAdd adds an account and an initial address and reloads the configuration. // // The new account does not have a password, so cannot yet log in. Email can be // delivered. @@ -923,13 +924,96 @@ func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesP return nil } -// ClientConfig holds the client configuration for IMAP/Submission for a -// domain. -type ClientConfig struct { - Entries []ClientConfigEntry +type TLSMode uint8 + +const ( + TLSModeImmediate TLSMode = 0 + TLSModeSTARTTLS TLSMode = 1 + TLSModeNone TLSMode = 2 +) + +type ProtocolConfig struct { + Host dns.Domain + Port int + TLSMode TLSMode } -type ClientConfigEntry struct { +type ClientConfig struct { + IMAP ProtocolConfig + Submission ProtocolConfig +} + +// ClientConfigDomain returns a single IMAP and Submission client configuration for +// a domain. +func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) { + var haveIMAP, haveSubmission bool + + if _, ok := Conf.Domain(d); !ok { + return ClientConfig{}, fmt.Errorf("unknown domain") + } + + gather := func(l config.Listener) (done bool) { + host := Conf.Static.HostnameDomain + if l.Hostname != "" { + host = l.HostnameDomain + } + if !haveIMAP && l.IMAPS.Enabled { + rconfig.IMAP.Host = host + rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993) + rconfig.IMAP.TLSMode = TLSModeImmediate + haveIMAP = true + } + if !haveIMAP && l.IMAP.Enabled { + rconfig.IMAP.Host = host + rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143) + rconfig.IMAP.TLSMode = TLSModeSTARTTLS + if l.TLS == nil { + rconfig.IMAP.TLSMode = TLSModeNone + } + haveIMAP = true + } + if !haveSubmission && l.Submissions.Enabled { + rconfig.Submission.Host = host + rconfig.Submission.Port = config.Port(l.Submissions.Port, 465) + rconfig.Submission.TLSMode = TLSModeImmediate + haveSubmission = true + } + if !haveSubmission && l.Submission.Enabled { + rconfig.Submission.Host = host + rconfig.Submission.Port = config.Port(l.Submission.Port, 587) + rconfig.Submission.TLSMode = TLSModeSTARTTLS + if l.TLS == nil { + rconfig.Submission.TLSMode = TLSModeNone + } + haveSubmission = true + } + return haveIMAP && haveSubmission + } + + // Look at the public listener first. Most likely the intended configuration. + if public, ok := Conf.Static.Listeners["public"]; ok { + if gather(public) { + return + } + } + // Go through the other listeners in consistent order. + names := maps.Keys(Conf.Static.Listeners) + sort.Strings(names) + for _, name := range names { + if gather(Conf.Static.Listeners[name]) { + return + } + } + return ClientConfig{}, fmt.Errorf("no listeners found for imap and/or submission") +} + +// ClientConfigs holds the client configuration for IMAP/Submission for a +// domain. +type ClientConfigs struct { + Entries []ClientConfigsEntry +} + +type ClientConfigsEntry struct { Protocol string Host dns.Domain Port int @@ -937,16 +1021,16 @@ type ClientConfigEntry struct { Note string } -// ClientConfigDomain returns the client config for IMAP/Submission for a +// ClientConfigsDomain returns the client configs for IMAP/Submission for a // domain. -func ClientConfigDomain(d dns.Domain) (ClientConfig, error) { +func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { _, ok := Conf.Domain(d) if !ok { - return ClientConfig{}, fmt.Errorf("unknown domain") + return ClientConfigs{}, fmt.Errorf("unknown domain") } - c := ClientConfig{} - c.Entries = []ClientConfigEntry{} + c := ClientConfigs{} + c.Entries = []ClientConfigsEntry{} var listeners []string for name := range Conf.Static.Listeners { @@ -973,16 +1057,16 @@ func ClientConfigDomain(d dns.Domain) (ClientConfig, error) { host = l.HostnameDomain } if l.Submissions.Enabled { - c.Entries = append(c.Entries, ClientConfigEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"}) + c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"}) } if l.IMAPS.Enabled { - c.Entries = append(c.Entries, ClientConfigEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"}) + c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"}) } if l.Submission.Enabled { - c.Entries = append(c.Entries, ClientConfigEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)}) + c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)}) } if l.IMAP.Enabled { - c.Entries = append(c.Entries, ClientConfigEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)}) + c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)}) } } diff --git a/vendor/modules.txt b/vendor/modules.txt index 229ba40..b42cc17 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -156,3 +156,8 @@ google.golang.org/protobuf/runtime/protoiface google.golang.org/protobuf/runtime/protoimpl google.golang.org/protobuf/types/descriptorpb google.golang.org/protobuf/types/known/timestamppb +# rsc.io/qr v0.2.0 +## explicit +rsc.io/qr +rsc.io/qr/coding +rsc.io/qr/gf256 diff --git a/vendor/rsc.io/qr/LICENSE b/vendor/rsc.io/qr/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/vendor/rsc.io/qr/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/rsc.io/qr/README.md b/vendor/rsc.io/qr/README.md new file mode 100644 index 0000000..0ba6214 --- /dev/null +++ b/vendor/rsc.io/qr/README.md @@ -0,0 +1,3 @@ +Basic QR encoder. + +go get [-u] rsc.io/qr diff --git a/vendor/rsc.io/qr/coding/qr.go b/vendor/rsc.io/qr/coding/qr.go new file mode 100644 index 0000000..bfc3ea4 --- /dev/null +++ b/vendor/rsc.io/qr/coding/qr.go @@ -0,0 +1,815 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package coding implements low-level QR coding details. +package coding // import "rsc.io/qr/coding" + +import ( + "fmt" + "strconv" + "strings" + + "rsc.io/qr/gf256" +) + +// Field is the field for QR error correction. +var Field = gf256.NewField(0x11d, 2) + +// A Version represents a QR version. +// The version specifies the size of the QR code: +// a QR code with version v has 4v+17 pixels on a side. +// Versions number from 1 to 40: the larger the version, +// the more information the code can store. +type Version int + +const MinVersion = 1 +const MaxVersion = 40 + +func (v Version) String() string { + return strconv.Itoa(int(v)) +} + +func (v Version) sizeClass() int { + if v <= 9 { + return 0 + } + if v <= 26 { + return 1 + } + return 2 +} + +// DataBytes returns the number of data bytes that can be +// stored in a QR code with the given version and level. +func (v Version) DataBytes(l Level) int { + vt := &vtab[v] + lev := &vt.level[l] + return vt.bytes - lev.nblock*lev.check +} + +// Encoding implements a QR data encoding scheme. +// The implementations--Numeric, Alphanumeric, and String--specify +// the character set and the mapping from UTF-8 to code bits. +// The more restrictive the mode, the fewer code bits are needed. +type Encoding interface { + Check() error + Bits(v Version) int + Encode(b *Bits, v Version) +} + +type Bits struct { + b []byte + nbit int +} + +func (b *Bits) Reset() { + b.b = b.b[:0] + b.nbit = 0 +} + +func (b *Bits) Bits() int { + return b.nbit +} + +func (b *Bits) Bytes() []byte { + if b.nbit%8 != 0 { + panic("fractional byte") + } + return b.b +} + +func (b *Bits) Append(p []byte) { + if b.nbit%8 != 0 { + panic("fractional byte") + } + b.b = append(b.b, p...) + b.nbit += 8 * len(p) +} + +func (b *Bits) Write(v uint, nbit int) { + for nbit > 0 { + n := nbit + if n > 8 { + n = 8 + } + if b.nbit%8 == 0 { + b.b = append(b.b, 0) + } else { + m := -b.nbit & 7 + if n > m { + n = m + } + } + b.nbit += n + sh := uint(nbit - n) + b.b[len(b.b)-1] |= uint8(v >> sh << uint(-b.nbit&7)) + v -= v >> sh << sh + nbit -= n + } +} + +// Num is the encoding for numeric data. +// The only valid characters are the decimal digits 0 through 9. +type Num string + +func (s Num) String() string { + return fmt.Sprintf("Num(%#q)", string(s)) +} + +func (s Num) Check() error { + for _, c := range s { + if c < '0' || '9' < c { + return fmt.Errorf("non-numeric string %#q", string(s)) + } + } + return nil +} + +var numLen = [3]int{10, 12, 14} + +func (s Num) Bits(v Version) int { + return 4 + numLen[v.sizeClass()] + (10*len(s)+2)/3 +} + +func (s Num) Encode(b *Bits, v Version) { + b.Write(1, 4) + b.Write(uint(len(s)), numLen[v.sizeClass()]) + var i int + for i = 0; i+3 <= len(s); i += 3 { + w := uint(s[i]-'0')*100 + uint(s[i+1]-'0')*10 + uint(s[i+2]-'0') + b.Write(w, 10) + } + switch len(s) - i { + case 1: + w := uint(s[i] - '0') + b.Write(w, 4) + case 2: + w := uint(s[i]-'0')*10 + uint(s[i+1]-'0') + b.Write(w, 7) + } +} + +// Alpha is the encoding for alphanumeric data. +// The valid characters are 0-9A-Z$%*+-./: and space. +type Alpha string + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + +func (s Alpha) String() string { + return fmt.Sprintf("Alpha(%#q)", string(s)) +} + +func (s Alpha) Check() error { + for _, c := range s { + if strings.IndexRune(alphabet, c) < 0 { + return fmt.Errorf("non-alphanumeric string %#q", string(s)) + } + } + return nil +} + +var alphaLen = [3]int{9, 11, 13} + +func (s Alpha) Bits(v Version) int { + return 4 + alphaLen[v.sizeClass()] + (11*len(s)+1)/2 +} + +func (s Alpha) Encode(b *Bits, v Version) { + b.Write(2, 4) + b.Write(uint(len(s)), alphaLen[v.sizeClass()]) + var i int + for i = 0; i+2 <= len(s); i += 2 { + w := uint(strings.IndexRune(alphabet, rune(s[i])))*45 + + uint(strings.IndexRune(alphabet, rune(s[i+1]))) + b.Write(w, 11) + } + + if i < len(s) { + w := uint(strings.IndexRune(alphabet, rune(s[i]))) + b.Write(w, 6) + } +} + +// String is the encoding for 8-bit data. All bytes are valid. +type String string + +func (s String) String() string { + return fmt.Sprintf("String(%#q)", string(s)) +} + +func (s String) Check() error { + return nil +} + +var stringLen = [3]int{8, 16, 16} + +func (s String) Bits(v Version) int { + return 4 + stringLen[v.sizeClass()] + 8*len(s) +} + +func (s String) Encode(b *Bits, v Version) { + b.Write(4, 4) + b.Write(uint(len(s)), stringLen[v.sizeClass()]) + for i := 0; i < len(s); i++ { + b.Write(uint(s[i]), 8) + } +} + +// A Pixel describes a single pixel in a QR code. +type Pixel uint32 + +const ( + Black Pixel = 1 << iota + Invert +) + +func (p Pixel) Offset() uint { + return uint(p >> 6) +} + +func OffsetPixel(o uint) Pixel { + return Pixel(o << 6) +} + +func (r PixelRole) Pixel() Pixel { + return Pixel(r << 2) +} + +func (p Pixel) Role() PixelRole { + return PixelRole(p>>2) & 15 +} + +func (p Pixel) String() string { + s := p.Role().String() + if p&Black != 0 { + s += "+black" + } + if p&Invert != 0 { + s += "+invert" + } + s += "+" + strconv.FormatUint(uint64(p.Offset()), 10) + return s +} + +// A PixelRole describes the role of a QR pixel. +type PixelRole uint32 + +const ( + _ PixelRole = iota + Position // position squares (large) + Alignment // alignment squares (small) + Timing // timing strip between position squares + Format // format metadata + PVersion // version pattern + Unused // unused pixel + Data // data bit + Check // error correction check bit + Extra +) + +var roles = []string{ + "", + "position", + "alignment", + "timing", + "format", + "pversion", + "unused", + "data", + "check", + "extra", +} + +func (r PixelRole) String() string { + if Position <= r && r <= Check { + return roles[r] + } + return strconv.Itoa(int(r)) +} + +// A Level represents a QR error correction level. +// From least to most tolerant of errors, they are L, M, Q, H. +type Level int + +const ( + L Level = iota + M + Q + H +) + +func (l Level) String() string { + if L <= l && l <= H { + return "LMQH"[l : l+1] + } + return strconv.Itoa(int(l)) +} + +// A Code is a square pixel grid. +type Code struct { + Bitmap []byte // 1 is black, 0 is white + Size int // number of pixels on a side + Stride int // number of bytes per row +} + +func (c *Code) Black(x, y int) bool { + return 0 <= x && x < c.Size && 0 <= y && y < c.Size && + c.Bitmap[y*c.Stride+x/8]&(1<= pad { + break + } + b.Write(0x11, 8) + } + } +} + +func (b *Bits) AddCheckBytes(v Version, l Level) { + nd := v.DataBytes(l) + if b.nbit < nd*8 { + b.Pad(nd*8 - b.nbit) + } + if b.nbit != nd*8 { + panic("qr: too much data") + } + + dat := b.Bytes() + vt := &vtab[v] + lev := &vt.level[l] + db := nd / lev.nblock + extra := nd % lev.nblock + chk := make([]byte, lev.check) + rs := gf256.NewRSEncoder(Field, lev.check) + for i := 0; i < lev.nblock; i++ { + if i == lev.nblock-extra { + db++ + } + rs.ECC(dat[:db], chk) + b.Append(chk) + dat = dat[db:] + } + + if len(b.Bytes()) != vt.bytes { + panic("qr: internal error") + } +} + +func (p *Plan) Encode(text ...Encoding) (*Code, error) { + var b Bits + for _, t := range text { + if err := t.Check(); err != nil { + return nil, err + } + t.Encode(&b, p.Version) + } + if b.Bits() > p.DataBytes*8 { + return nil, fmt.Errorf("cannot encode %d bits into %d-bit code", b.Bits(), p.DataBytes*8) + } + b.AddCheckBytes(p.Version, p.Level) + bytes := b.Bytes() + + // Now we have the checksum bytes and the data bytes. + // Construct the actual code. + c := &Code{Size: len(p.Pixel), Stride: (len(p.Pixel) + 7) &^ 7} + c.Bitmap = make([]byte, c.Stride*c.Size) + crow := c.Bitmap + for _, row := range p.Pixel { + for x, pix := range row { + switch pix.Role() { + case Data, Check: + o := pix.Offset() + if bytes[o/8]&(1< 40 { + return nil, fmt.Errorf("invalid QR version %d", int(v)) + } + siz := 17 + int(v)*4 + m := grid(siz) + p.Pixel = m + + // Timing markers (overwritten by boxes). + const ti = 6 // timing is in row/column 6 (counting from 0) + for i := range m { + p := Timing.Pixel() + if i&1 == 0 { + p |= Black + } + m[i][ti] = p + m[ti][i] = p + } + + // Position boxes. + posBox(m, 0, 0) + posBox(m, siz-7, 0) + posBox(m, 0, siz-7) + + // Alignment boxes. + info := &vtab[v] + for x := 4; x+5 < siz; { + for y := 4; y+5 < siz; { + // don't overwrite timing markers + if (x < 7 && y < 7) || (x < 7 && y+5 >= siz-7) || (x+5 >= siz-7 && y < 7) { + } else { + alignBox(m, x, y) + } + if y == 4 { + y = info.apos + } else { + y += info.astride + } + } + if x == 4 { + x = info.apos + } else { + x += info.astride + } + } + + // Version pattern. + pat := vtab[v].pattern + if pat != 0 { + v := pat + for x := 0; x < 6; x++ { + for y := 0; y < 3; y++ { + p := PVersion.Pixel() + if v&1 != 0 { + p |= Black + } + m[siz-11+y][x] = p + m[x][siz-11+y] = p + v >>= 1 + } + } + } + + // One lonely black pixel + m[siz-8][8] = Unused.Pixel() | Black + + return p, nil +} + +// fplan adds the format pixels +func fplan(l Level, m Mask, p *Plan) error { + // Format pixels. + fb := uint32(l^1) << 13 // level: L=01, M=00, Q=11, H=10 + fb |= uint32(m) << 10 // mask + const formatPoly = 0x537 + rem := fb + for i := 14; i >= 10; i-- { + if rem&(1<>i)&1 == 1 { + pix |= Black + } + if (invert>>i)&1 == 1 { + pix ^= Invert | Black + } + // top left + switch { + case i < 6: + p.Pixel[i][8] = pix + case i < 8: + p.Pixel[i+1][8] = pix + case i < 9: + p.Pixel[8][7] = pix + default: + p.Pixel[8][14-i] = pix + } + // bottom right + switch { + case i < 8: + p.Pixel[8][siz-1-int(i)] = pix + default: + p.Pixel[siz-1-int(14-i)][8] = pix + } + } + return nil +} + +// lplan edits a version-only Plan to add information +// about the error correction levels. +func lplan(v Version, l Level, p *Plan) error { + p.Level = l + + nblock := vtab[v].level[l].nblock + ne := vtab[v].level[l].check + nde := (vtab[v].bytes - ne*nblock) / nblock + extra := (vtab[v].bytes - ne*nblock) % nblock + dataBits := (nde*nblock + extra) * 8 + checkBits := ne * nblock * 8 + + p.DataBytes = vtab[v].bytes - ne*nblock + p.CheckBytes = ne * nblock + p.Blocks = nblock + + // Make data + checksum pixels. + data := make([]Pixel, dataBits) + for i := range data { + data[i] = Data.Pixel() | OffsetPixel(uint(i)) + } + check := make([]Pixel, checkBits) + for i := range check { + check[i] = Check.Pixel() | OffsetPixel(uint(i+dataBits)) + } + + // Split into blocks. + dataList := make([][]Pixel, nblock) + checkList := make([][]Pixel, nblock) + for i := 0; i < nblock; i++ { + // The last few blocks have an extra data byte (8 pixels). + nd := nde + if i >= nblock-extra { + nd++ + } + dataList[i], data = data[0:nd*8], data[nd*8:] + checkList[i], check = check[0:ne*8], check[ne*8:] + } + if len(data) != 0 || len(check) != 0 { + panic("data/check math") + } + + // Build up bit sequence, taking first byte of each block, + // then second byte, and so on. Then checksums. + bits := make([]Pixel, dataBits+checkBits) + dst := bits + for i := 0; i < nde+1; i++ { + for _, b := range dataList { + if i*8 < len(b) { + copy(dst, b[i*8:(i+1)*8]) + dst = dst[8:] + } + } + } + for i := 0; i < ne; i++ { + for _, b := range checkList { + if i*8 < len(b) { + copy(dst, b[i*8:(i+1)*8]) + dst = dst[8:] + } + } + } + if len(dst) != 0 { + panic("dst math") + } + + // Sweep up pair of columns, + // then down, assigning to right then left pixel. + // Repeat. + // See Figure 2 of http://www.pclviewer.com/rs2/qrtopology.htm + siz := len(p.Pixel) + rem := make([]Pixel, 7) + for i := range rem { + rem[i] = Extra.Pixel() + } + src := append(bits, rem...) + for x := siz; x > 0; { + for y := siz - 1; y >= 0; y-- { + if p.Pixel[y][x-1].Role() == 0 { + p.Pixel[y][x-1], src = src[0], src[1:] + } + if p.Pixel[y][x-2].Role() == 0 { + p.Pixel[y][x-2], src = src[0], src[1:] + } + } + x -= 2 + if x == 7 { // vertical timing strip + x-- + } + for y := 0; y < siz; y++ { + if p.Pixel[y][x-1].Role() == 0 { + p.Pixel[y][x-1], src = src[0], src[1:] + } + if p.Pixel[y][x-2].Role() == 0 { + p.Pixel[y][x-2], src = src[0], src[1:] + } + } + x -= 2 + } + return nil +} + +// mplan edits a version+level-only Plan to add the mask. +func mplan(m Mask, p *Plan) error { + p.Mask = m + for y, row := range p.Pixel { + for x, pix := range row { + if r := pix.Role(); (r == Data || r == Check || r == Extra) && p.Mask.Invert(y, x) { + row[x] ^= Black | Invert + } + } + } + return nil +} + +// posBox draws a position (large) box at upper left x, y. +func posBox(m [][]Pixel, x, y int) { + pos := Position.Pixel() + // box + for dy := 0; dy < 7; dy++ { + for dx := 0; dx < 7; dx++ { + p := pos + if dx == 0 || dx == 6 || dy == 0 || dy == 6 || 2 <= dx && dx <= 4 && 2 <= dy && dy <= 4 { + p |= Black + } + m[y+dy][x+dx] = p + } + } + // white border + for dy := -1; dy < 8; dy++ { + if 0 <= y+dy && y+dy < len(m) { + if x > 0 { + m[y+dy][x-1] = pos + } + if x+7 < len(m) { + m[y+dy][x+7] = pos + } + } + } + for dx := -1; dx < 8; dx++ { + if 0 <= x+dx && x+dx < len(m) { + if y > 0 { + m[y-1][x+dx] = pos + } + if y+7 < len(m) { + m[y+7][x+dx] = pos + } + } + } +} + +// alignBox draw an alignment (small) box at upper left x, y. +func alignBox(m [][]Pixel, x, y int) { + // box + align := Alignment.Pixel() + for dy := 0; dy < 5; dy++ { + for dx := 0; dx < 5; dx++ { + p := align + if dx == 0 || dx == 4 || dy == 0 || dy == 4 || dx == 2 && dy == 2 { + p |= Black + } + m[y+dy][x+dx] = p + } + } +} diff --git a/vendor/rsc.io/qr/gf256/gf256.go b/vendor/rsc.io/qr/gf256/gf256.go new file mode 100644 index 0000000..05e5645 --- /dev/null +++ b/vendor/rsc.io/qr/gf256/gf256.go @@ -0,0 +1,241 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package gf256 implements arithmetic over the Galois Field GF(256). +package gf256 // import "rsc.io/qr/gf256" + +import "strconv" + +// A Field represents an instance of GF(256) defined by a specific polynomial. +type Field struct { + log [256]byte // log[0] is unused + exp [510]byte +} + +// NewField returns a new field corresponding to the polynomial poly +// and generator α. The Reed-Solomon encoding in QR codes uses +// polynomial 0x11d with generator 2. +// +// The choice of generator α only affects the Exp and Log operations. +func NewField(poly, α int) *Field { + if poly < 0x100 || poly >= 0x200 || reducible(poly) { + panic("gf256: invalid polynomial: " + strconv.Itoa(poly)) + } + + var f Field + x := 1 + for i := 0; i < 255; i++ { + if x == 1 && i != 0 { + panic("gf256: invalid generator " + strconv.Itoa(α) + + " for polynomial " + strconv.Itoa(poly)) + } + f.exp[i] = byte(x) + f.exp[i+255] = byte(x) + f.log[x] = byte(i) + x = mul(x, α, poly) + } + f.log[0] = 255 + for i := 0; i < 255; i++ { + if f.log[f.exp[i]] != byte(i) { + panic("bad log") + } + if f.log[f.exp[i+255]] != byte(i) { + panic("bad log") + } + } + for i := 1; i < 256; i++ { + if f.exp[f.log[i]] != byte(i) { + panic("bad log") + } + } + + return &f +} + +// nbit returns the number of significant in p. +func nbit(p int) uint { + n := uint(0) + for ; p > 0; p >>= 1 { + n++ + } + return n +} + +// polyDiv divides the polynomial p by q and returns the remainder. +func polyDiv(p, q int) int { + np := nbit(p) + nq := nbit(q) + for ; np >= nq; np-- { + if p&(1<<(np-1)) != 0 { + p ^= q << (np - nq) + } + } + return p +} + +// mul returns the product x*y mod poly, a GF(256) multiplication. +func mul(x, y, poly int) int { + z := 0 + for x > 0 { + if x&1 != 0 { + z ^= y + } + x >>= 1 + y <<= 1 + if y&0x100 != 0 { + y ^= poly + } + } + return z +} + +// reducible reports whether p is reducible. +func reducible(p int) bool { + // Multiplying n-bit * n-bit produces (2n-1)-bit, + // so if p is reducible, one of its factors must be + // of np/2+1 bits or fewer. + np := nbit(p) + for q := 2; q < 1<<(np/2+1); q++ { + if polyDiv(p, q) == 0 { + return true + } + } + return false +} + +// Add returns the sum of x and y in the field. +func (f *Field) Add(x, y byte) byte { + return x ^ y +} + +// Exp returns the base-α exponential of e in the field. +// If e < 0, Exp returns 0. +func (f *Field) Exp(e int) byte { + if e < 0 { + return 0 + } + return f.exp[e%255] +} + +// Log returns the base-α logarithm of x in the field. +// If x == 0, Log returns -1. +func (f *Field) Log(x byte) int { + if x == 0 { + return -1 + } + return int(f.log[x]) +} + +// Inv returns the multiplicative inverse of x in the field. +// If x == 0, Inv returns 0. +func (f *Field) Inv(x byte) byte { + if x == 0 { + return 0 + } + return f.exp[255-f.log[x]] +} + +// Mul returns the product of x and y in the field. +func (f *Field) Mul(x, y byte) byte { + if x == 0 || y == 0 { + return 0 + } + return f.exp[int(f.log[x])+int(f.log[y])] +} + +// An RSEncoder implements Reed-Solomon encoding +// over a given field using a given number of error correction bytes. +type RSEncoder struct { + f *Field + c int + gen []byte + lgen []byte + p []byte +} + +func (f *Field) gen(e int) (gen, lgen []byte) { + // p = 1 + p := make([]byte, e+1) + p[e] = 1 + + for i := 0; i < e; i++ { + // p *= (x + Exp(i)) + // p[j] = p[j]*Exp(i) + p[j+1]. + c := f.Exp(i) + for j := 0; j < e; j++ { + p[j] = f.Mul(p[j], c) ^ p[j+1] + } + p[e] = f.Mul(p[e], c) + } + + // lp = log p. + lp := make([]byte, e+1) + for i, c := range p { + if c == 0 { + lp[i] = 255 + } else { + lp[i] = byte(f.Log(c)) + } + } + + return p, lp +} + +// NewRSEncoder returns a new Reed-Solomon encoder +// over the given field and number of error correction bytes. +func NewRSEncoder(f *Field, c int) *RSEncoder { + gen, lgen := f.gen(c) + return &RSEncoder{f: f, c: c, gen: gen, lgen: lgen} +} + +// ECC writes to check the error correcting code bytes +// for data using the given Reed-Solomon parameters. +func (rs *RSEncoder) ECC(data []byte, check []byte) { + if len(check) < rs.c { + panic("gf256: invalid check byte length") + } + if rs.c == 0 { + return + } + + // The check bytes are the remainder after dividing + // data padded with c zeros by the generator polynomial. + + // p = data padded with c zeros. + var p []byte + n := len(data) + rs.c + if len(rs.p) >= n { + p = rs.p + } else { + p = make([]byte, n) + } + copy(p, data) + for i := len(data); i < len(p); i++ { + p[i] = 0 + } + + // Divide p by gen, leaving the remainder in p[len(data):]. + // p[0] is the most significant term in p, and + // gen[0] is the most significant term in the generator, + // which is always 1. + // To avoid repeated work, we store various values as + // lv, not v, where lv = log[v]. + f := rs.f + lgen := rs.lgen[1:] + for i := 0; i < len(data); i++ { + c := p[i] + if c == 0 { + continue + } + q := p[i+1:] + exp := f.exp[f.log[c]:] + for j, lg := range lgen { + if lg != 255 { // lgen uses 255 for log 0 + q[j] ^= exp[lg] + } + } + } + copy(check, p[len(data):]) + rs.p = p +} diff --git a/vendor/rsc.io/qr/png.go b/vendor/rsc.io/qr/png.go new file mode 100644 index 0000000..db49d05 --- /dev/null +++ b/vendor/rsc.io/qr/png.go @@ -0,0 +1,400 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package qr + +// PNG writer for QR codes. + +import ( + "bytes" + "encoding/binary" + "hash" + "hash/crc32" +) + +// PNG returns a PNG image displaying the code. +// +// PNG uses a custom encoder tailored to QR codes. +// Its compressed size is about 2x away from optimal, +// but it runs about 20x faster than calling png.Encode +// on c.Image(). +func (c *Code) PNG() []byte { + var p pngWriter + return p.encode(c) +} + +type pngWriter struct { + tmp [16]byte + wctmp [4]byte + buf bytes.Buffer + zlib bitWriter + crc hash.Hash32 +} + +var pngHeader = []byte("\x89PNG\r\n\x1a\n") + +func (w *pngWriter) encode(c *Code) []byte { + scale := c.Scale + siz := c.Size + + w.buf.Reset() + + // Header + w.buf.Write(pngHeader) + + // Header block + binary.BigEndian.PutUint32(w.tmp[0:4], uint32((siz+8)*scale)) + binary.BigEndian.PutUint32(w.tmp[4:8], uint32((siz+8)*scale)) + w.tmp[8] = 1 // 1-bit + w.tmp[9] = 0 // gray + w.tmp[10] = 0 + w.tmp[11] = 0 + w.tmp[12] = 0 + w.writeChunk("IHDR", w.tmp[:13]) + + // Comment + w.writeChunk("tEXt", comment) + + // Data + w.zlib.writeCode(c) + w.writeChunk("IDAT", w.zlib.bytes.Bytes()) + + // End + w.writeChunk("IEND", nil) + + return w.buf.Bytes() +} + +var comment = []byte("Software\x00QR-PNG http://qr.swtch.com/") + +func (w *pngWriter) writeChunk(name string, data []byte) { + if w.crc == nil { + w.crc = crc32.NewIEEE() + } + binary.BigEndian.PutUint32(w.wctmp[0:4], uint32(len(data))) + w.buf.Write(w.wctmp[0:4]) + w.crc.Reset() + copy(w.wctmp[0:4], name) + w.buf.Write(w.wctmp[0:4]) + w.crc.Write(w.wctmp[0:4]) + w.buf.Write(data) + w.crc.Write(data) + crc := w.crc.Sum32() + binary.BigEndian.PutUint32(w.wctmp[0:4], crc) + w.buf.Write(w.wctmp[0:4]) +} + +func (b *bitWriter) writeCode(c *Code) { + const ftNone = 0 + + b.adler32.Reset() + b.bytes.Reset() + b.nbit = 0 + + scale := c.Scale + siz := c.Size + + // zlib header + b.tmp[0] = 0x78 + b.tmp[1] = 0 + b.tmp[1] += uint8(31 - (uint16(b.tmp[0])<<8+uint16(b.tmp[1]))%31) + b.bytes.Write(b.tmp[0:2]) + + // Start flate block. + b.writeBits(1, 1, false) // final block + b.writeBits(1, 2, false) // compressed, fixed Huffman tables + + // White border. + // First row. + b.byte(ftNone) + n := (scale*(siz+8) + 7) / 8 + b.byte(255) + b.repeat(n-1, 1) + // 4*scale rows total. + b.repeat((4*scale-1)*(1+n), 1+n) + + for i := 0; i < 4*scale; i++ { + b.adler32.WriteNByte(ftNone, 1) + b.adler32.WriteNByte(255, n) + } + + row := make([]byte, 1+n) + for y := 0; y < siz; y++ { + row[0] = ftNone + j := 1 + var z uint8 + nz := 0 + for x := -4; x < siz+4; x++ { + // Raw data. + for i := 0; i < scale; i++ { + z <<= 1 + if !c.Black(x, y) { + z |= 1 + } + if nz++; nz == 8 { + row[j] = z + j++ + nz = 0 + } + } + } + if j < len(row) { + row[j] = z + } + for _, z := range row { + b.byte(z) + } + + // Scale-1 copies. + b.repeat((scale-1)*(1+n), 1+n) + + b.adler32.WriteN(row, scale) + } + + // White border. + // First row. + b.byte(ftNone) + b.byte(255) + b.repeat(n-1, 1) + // 4*scale rows total. + b.repeat((4*scale-1)*(1+n), 1+n) + + for i := 0; i < 4*scale; i++ { + b.adler32.WriteNByte(ftNone, 1) + b.adler32.WriteNByte(255, n) + } + + // End of block. + b.hcode(256) + b.flushBits() + + // adler32 + binary.BigEndian.PutUint32(b.tmp[0:], b.adler32.Sum32()) + b.bytes.Write(b.tmp[0:4]) +} + +// A bitWriter is a write buffer for bit-oriented data like deflate. +type bitWriter struct { + bytes bytes.Buffer + bit uint32 + nbit uint + + tmp [4]byte + adler32 adigest +} + +func (b *bitWriter) writeBits(bit uint32, nbit uint, rev bool) { + // reverse, for huffman codes + if rev { + br := uint32(0) + for i := uint(0); i < nbit; i++ { + br |= ((bit >> i) & 1) << (nbit - 1 - i) + } + bit = br + } + b.bit |= bit << b.nbit + b.nbit += nbit + for b.nbit >= 8 { + b.bytes.WriteByte(byte(b.bit)) + b.bit >>= 8 + b.nbit -= 8 + } +} + +func (b *bitWriter) flushBits() { + if b.nbit > 0 { + b.bytes.WriteByte(byte(b.bit)) + b.nbit = 0 + b.bit = 0 + } +} + +func (b *bitWriter) hcode(v int) { + /* + Lit Value Bits Codes + --------- ---- ----- + 0 - 143 8 00110000 through + 10111111 + 144 - 255 9 110010000 through + 111111111 + 256 - 279 7 0000000 through + 0010111 + 280 - 287 8 11000000 through + 11000111 + */ + switch { + case v <= 143: + b.writeBits(uint32(v)+0x30, 8, true) + case v <= 255: + b.writeBits(uint32(v-144)+0x190, 9, true) + case v <= 279: + b.writeBits(uint32(v-256)+0, 7, true) + case v <= 287: + b.writeBits(uint32(v-280)+0xc0, 8, true) + default: + panic("invalid hcode") + } +} + +func (b *bitWriter) byte(x byte) { + b.hcode(int(x)) +} + +func (b *bitWriter) codex(c int, val int, nx uint) { + b.hcode(c + val>>nx) + b.writeBits(uint32(val)&(1<= 258+3; n -= 258 { + b.repeat1(258, d) + } + if n > 258 { + // 258 < n < 258+3 + b.repeat1(10, d) + b.repeat1(n-10, d) + return + } + if n < 3 { + panic("invalid flate repeat") + } + b.repeat1(n, d) +} + +func (b *bitWriter) repeat1(n, d int) { + /* + Extra Extra Extra + Code Bits Length(s) Code Bits Lengths Code Bits Length(s) + ---- ---- ------ ---- ---- ------- ---- ---- ------- + 257 0 3 267 1 15,16 277 4 67-82 + 258 0 4 268 1 17,18 278 4 83-98 + 259 0 5 269 2 19-22 279 4 99-114 + 260 0 6 270 2 23-26 280 4 115-130 + 261 0 7 271 2 27-30 281 5 131-162 + 262 0 8 272 2 31-34 282 5 163-194 + 263 0 9 273 3 35-42 283 5 195-226 + 264 0 10 274 3 43-50 284 5 227-257 + 265 1 11,12 275 3 51-58 285 0 258 + 266 1 13,14 276 3 59-66 + */ + switch { + case n <= 10: + b.codex(257, n-3, 0) + case n <= 18: + b.codex(265, n-11, 1) + case n <= 34: + b.codex(269, n-19, 2) + case n <= 66: + b.codex(273, n-35, 3) + case n <= 130: + b.codex(277, n-67, 4) + case n <= 257: + b.codex(281, n-131, 5) + case n == 258: + b.hcode(285) + default: + panic("invalid repeat length") + } + + /* + Extra Extra Extra + Code Bits Dist Code Bits Dist Code Bits Distance + ---- ---- ---- ---- ---- ------ ---- ---- -------- + 0 0 1 10 4 33-48 20 9 1025-1536 + 1 0 2 11 4 49-64 21 9 1537-2048 + 2 0 3 12 5 65-96 22 10 2049-3072 + 3 0 4 13 5 97-128 23 10 3073-4096 + 4 1 5,6 14 6 129-192 24 11 4097-6144 + 5 1 7,8 15 6 193-256 25 11 6145-8192 + 6 2 9-12 16 7 257-384 26 12 8193-12288 + 7 2 13-16 17 7 385-512 27 12 12289-16384 + 8 3 17-24 18 8 513-768 28 13 16385-24576 + 9 3 25-32 19 8 769-1024 29 13 24577-32768 + */ + if d <= 4 { + b.writeBits(uint32(d-1), 5, true) + } else if d <= 32768 { + nbit := uint(16) + for d <= 1<<(nbit-1) { + nbit-- + } + v := uint32(d - 1) + v &^= 1 << (nbit - 1) // top bit is implicit + code := uint32(2*nbit - 2) // second bit is low bit of code + code |= v >> (nbit - 2) + v &^= 1 << (nbit - 2) + b.writeBits(code, 5, true) + // rest of bits follow + b.writeBits(uint32(v), nbit-2, false) + } else { + panic("invalid repeat distance") + } +} + +func (b *bitWriter) run(v byte, n int) { + if n == 0 { + return + } + b.byte(v) + if n-1 < 3 { + for i := 0; i < n-1; i++ { + b.byte(v) + } + } else { + b.repeat(n-1, 1) + } +} + +type adigest struct { + a, b uint32 +} + +func (d *adigest) Reset() { d.a, d.b = 1, 0 } + +const amod = 65521 + +func aupdate(a, b uint32, pi byte, n int) (aa, bb uint32) { + // TODO(rsc): 6g doesn't do magic multiplies for b %= amod, + // only for b = b%amod. + + // invariant: a, b < amod + if pi == 0 { + b += uint32(n%amod) * a + b = b % amod + return a, b + } + + // n times: + // a += pi + // b += a + // is same as + // b += n*a + n*(n+1)/2*pi + // a += n*pi + m := uint32(n) + b += (m % amod) * a + b = b % amod + b += (m * (m + 1) / 2) % amod * uint32(pi) + b = b % amod + a += (m % amod) * uint32(pi) + a = a % amod + return a, b +} + +func afinish(a, b uint32) uint32 { + return b<<16 | a +} + +func (d *adigest) WriteN(p []byte, n int) { + for i := 0; i < n; i++ { + for _, pi := range p { + d.a, d.b = aupdate(d.a, d.b, pi, 1) + } + } +} + +func (d *adigest) WriteNByte(pi byte, n int) { + d.a, d.b = aupdate(d.a, d.b, pi, n) +} + +func (d *adigest) Sum32() uint32 { return afinish(d.a, d.b) } diff --git a/vendor/rsc.io/qr/qr.go b/vendor/rsc.io/qr/qr.go new file mode 100644 index 0000000..ace7e6f --- /dev/null +++ b/vendor/rsc.io/qr/qr.go @@ -0,0 +1,116 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package qr encodes QR codes. +*/ +package qr // import "rsc.io/qr" + +import ( + "errors" + "image" + "image/color" + + "rsc.io/qr/coding" +) + +// A Level denotes a QR error correction level. +// From least to most tolerant of errors, they are L, M, Q, H. +type Level int + +const ( + L Level = iota // 20% redundant + M // 38% redundant + Q // 55% redundant + H // 65% redundant +) + +// Encode returns an encoding of text at the given error correction level. +func Encode(text string, level Level) (*Code, error) { + // Pick data encoding, smallest first. + // We could split the string and use different encodings + // but that seems like overkill for now. + var enc coding.Encoding + switch { + case coding.Num(text).Check() == nil: + enc = coding.Num(text) + case coding.Alpha(text).Check() == nil: + enc = coding.Alpha(text) + default: + enc = coding.String(text) + } + + // Pick size. + l := coding.Level(level) + var v coding.Version + for v = coding.MinVersion; ; v++ { + if v > coding.MaxVersion { + return nil, errors.New("text too long to encode as QR") + } + if enc.Bits(v) <= v.DataBytes(l)*8 { + break + } + } + + // Build and execute plan. + p, err := coding.NewPlan(v, l, 0) + if err != nil { + return nil, err + } + cc, err := p.Encode(enc) + if err != nil { + return nil, err + } + + // TODO: Pick appropriate mask. + + return &Code{cc.Bitmap, cc.Size, cc.Stride, 8}, nil +} + +// A Code is a square pixel grid. +// It implements image.Image and direct PNG encoding. +type Code struct { + Bitmap []byte // 1 is black, 0 is white + Size int // number of pixels on a side + Stride int // number of bytes per row + Scale int // number of image pixels per QR pixel +} + +// Black returns true if the pixel at (x,y) is black. +func (c *Code) Black(x, y int) bool { + return 0 <= x && x < c.Size && 0 <= y && y < c.Size && + c.Bitmap[y*c.Stride+x/8]&(1< { } page.classList.add('loading') await api.DestinationSave(name, dest, newDest) - dest = newDest // Set new dest, for if user edits again. Without this, they would get an error that the config has been modified. + window.location.reload() // todo: only refresh part of ui } catch (err) { console.log({err}) window.alert('Error: '+err.message) + page.classList.remove('loading') return } finally { saveButton.disabled = false - page.classList.remove('loading') } }), + dom.br(), + dom.br(), + dom.br(), + dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", + dom.br(), + dom.a(attr({href: 'https://autoconfig.'+domainName(domain)+'/profile.mobileconfig?address='+encodeURIComponent(name)+'&name='+encodeURIComponent(dest.FullName), download: ''}), 'Download .mobileconfig email account profile'), + dom.br(), + dom.a(attr({href: 'https://autoconfig.'+domainName(domain)+'/profile.mobileconfig.qrcode.png?address='+encodeURIComponent(name)+'&name='+encodeURIComponent(dest.FullName), download: ''}), 'Open QR-code with link to .mobileconfig profile'), + ), ) } diff --git a/webadmin/admin.go b/webadmin/admin.go index 379daaa..6d83ddb 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -1578,7 +1578,8 @@ func (Admin) DomainRemove(ctx context.Context, domain string) { xcheckf(ctx, err, "removing domain") } -// AccountAdd adds existing a new account, with an initial email address, and reloads the configuration. +// AccountAdd adds existing a new account, with an initial email address, and +// reloads the configuration. func (Admin) AccountAdd(ctx context.Context, accountName, address string) { err := mox.AccountAdd(ctx, accountName, address) xcheckf(ctx, err, "adding account") @@ -1625,13 +1626,13 @@ func (Admin) SetAccountLimits(ctx context.Context, accountName string, maxOutgoi xcheckf(ctx, err, "saving account limits") } -// ClientConfigDomain returns configurations for email clients, IMAP and +// ClientConfigsDomain returns configurations for email clients, IMAP and // Submission (SMTP) for the domain. -func (Admin) ClientConfigDomain(ctx context.Context, domain string) mox.ClientConfig { +func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs { d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") - cc, err := mox.ClientConfigDomain(d) + cc, err := mox.ClientConfigsDomain(d) xcheckf(ctx, err, "client config for domain") return cc } diff --git a/webadmin/admin.html b/webadmin/admin.html index 823b60c..dd07982 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -695,12 +695,12 @@ const account = async (name) => { const domain = async (d) => { const end = new Date().toISOString() const start = new Date(new Date().getTime() - 30*24*3600*1000).toISOString() - const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfig] = await Promise.all([ + const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs] = await Promise.all([ api.DMARCSummaries(start, end, d), api.TLSRPTSummaries(start, end, d), api.DomainLocalparts(d), api.Domain(d), - api.ClientConfigDomain(d), + api.ClientConfigsDomain(d), ]) let form, fieldset, localpart, account @@ -725,7 +725,7 @@ const domain = async (d) => { ), ), dom.tbody( - clientConfig.Entries.map(e => + clientConfigs.Entries.map(e => dom.tr( dom.td(e.Protocol), dom.td(domainString(e.Host)), @@ -756,7 +756,7 @@ const domain = async (d) => { dom.td(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr({href: '#accounts/'+t[1]}))), dom.td( - dom.button('Remove address', async function click(e) { + dom.button('Remove', async function click(e) { e.preventDefault() if (!window.confirm('Are you sure you want to remove this address?')) { return diff --git a/webadmin/adminapi.json b/webadmin/adminapi.json index e169c0b..a0cf9fd 100644 --- a/webadmin/adminapi.json +++ b/webadmin/adminapi.json @@ -437,7 +437,7 @@ }, { "Name": "AccountAdd", - "Docs": "AccountAdd adds existing a new account, with an initial email address, and reloads the configuration.", + "Docs": "AccountAdd adds existing a new account, with an initial email address, and\nreloads the configuration.", "Params": [ { "Name": "accountName", @@ -544,8 +544,8 @@ "Returns": [] }, { - "Name": "ClientConfigDomain", - "Docs": "ClientConfigDomain returns configurations for email clients, IMAP and\nSubmission (SMTP) for the domain.", + "Name": "ClientConfigsDomain", + "Docs": "ClientConfigsDomain returns configurations for email clients, IMAP and\nSubmission (SMTP) for the domain.", "Params": [ { "Name": "domain", @@ -558,7 +558,7 @@ { "Name": "r0", "Typewords": [ - "ClientConfig" + "ClientConfigs" ] } ] @@ -2772,21 +2772,21 @@ ] }, { - "Name": "ClientConfig", - "Docs": "ClientConfig holds the client configuration for IMAP/Submission for a\ndomain.", + "Name": "ClientConfigs", + "Docs": "ClientConfigs holds the client configuration for IMAP/Submission for a\ndomain.", "Fields": [ { "Name": "Entries", "Docs": "", "Typewords": [ "[]", - "ClientConfigEntry" + "ClientConfigsEntry" ] } ] }, { - "Name": "ClientConfigEntry", + "Name": "ClientConfigsEntry", "Docs": "", "Fields": [ {