// automx answers mail clients' autoconfiguration requests for the domains mailu serves: Thunderbird's // /mail/config-v1.1.xml, Outlook's /autodiscover/autodiscover.xml and Apple's /mobileconfig. It replaces the // Python automx2 image (novox/hq ADR 0480 clauses 1, 9, 10): standard library only, no database, every answer // made from mailu's environment at the start, so a change of the domains, servers or ports reaches clients when // the program is started again. // // The answers are those automx2 2021.6 gave, captured in testdata/captured (CAPTURED, issue 1630), byte for byte, // but for the one thing testdata/captured/DIFFERENCES lists: the IMAP server is named, from AMX_IMAP_ADDRESS. package main import ( "encoding/xml" "errors" "fmt" "html" "io" "net/http" "os" "strconv" "strings" "time" ) // The environment, all required: a program that cannot say which servers clients use must not run (failure is // loud). The names are those of mailu's files/mailu.env. const ( envDomains = "AMX_MAIL_DOMAINS" envImapHost = "AMX_IMAP_ADDRESS" envImapPort = "AMX_IMAP_PORT" envSmtpHost = "AMX_SMTP_ADDRESS" envSmtpPort = "AMX_SMTP_PORT" envListen = "AUTOMX_PORT" ) type config struct { domains []string imapHost, smtpHost string imapPort, smtpPort int listen int } // configFrom reads the environment; it names every variable that is missing or wrong. func configFrom(getenv func(string) string) (config, error) { var c config var problems []string text := func(name string) string { v := strings.TrimSpace(getenv(name)) if v == "" { problems = append(problems, name+" is not set") } return v } port := func(name string) int { v := text(name) if v == "" { return 0 } n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 65535 { problems = append(problems, fmt.Sprintf("%s is %q, not a port number", name, v)) return 0 } return n } // automx2's add-domains split the list at commas and spaces. for _, d := range strings.FieldsFunc(text(envDomains), func(r rune) bool { return r == ',' || r == ' ' }) { c.domains = append(c.domains, strings.ToLower(d)) } c.imapHost, c.imapPort = text(envImapHost), port(envImapPort) c.smtpHost, c.smtpPort = text(envSmtpHost), port(envSmtpPort) c.listen = port(envListen) if len(problems) > 0 { return config{}, errors.New(strings.Join(problems, "; ")) } return c, nil } // What automx2 sent (Werkzeug), copied from the capture. const ( htmlType = "text/html; charset=utf-8" xmlType = "application/xml" notFound = ` 404 Not Found

Not Found

The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

` notAllowed = ` 405 Method Not Allowed

Method Not Allowed

The method is not allowed for the requested URL.

` internalError = ` 500 Internal Server Error

Internal Server Error

The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

` badRequest = ` 400 Bad Request

Bad Request

The browser (or proxy) sent a request that this server could not understand.

` index = `Show Thunderbird-style XML configuration for abc@example.com.` ) func reply(w http.ResponseWriter, status int, contentType, body string) { w.Header().Set("Content-Type", contentType) if status != http.StatusNoContent { w.Header().Set("Content-Length", strconv.Itoa(len(body))) } w.WriteHeader(status) if status != http.StatusNoContent { io.WriteString(w, body) } } func missing(w http.ResponseWriter, argument string) { reply(w, http.StatusBadRequest, htmlType, fmt.Sprintf("Missing request argument %q", argument)) } type handler struct{ config } func newHandler(c config) http.Handler { return handler{c} } func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var allow string switch r.URL.Path { case "/", "/mail/config-v1.1.xml", "/mobileconfig/": allow = "GET, HEAD, OPTIONS" case "/autodiscover/autodiscover.xml": allow = "POST, OPTIONS" case "/mobileconfig": h.redirectToSlash(w, r) return default: reply(w, http.StatusNotFound, htmlType, notFound) return } if r.Method == http.MethodOptions { w.Header().Set("Allow", allow) reply(w, http.StatusOK, htmlType, "") return } if !strings.Contains(", "+allow+",", ", "+r.Method+",") { w.Header().Set("Allow", allow) reply(w, http.StatusMethodNotAllowed, htmlType, notAllowed) return } switch r.URL.Path { case "/": reply(w, http.StatusOK, htmlType, index) case "/mail/config-v1.1.xml": h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.thunderbird) case "/mobileconfig/": h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.apple) case "/autodiscover/autodiscover.xml": h.outlook(w, r) } } // byAddress is the part the three protocols share: the address is required, and a domain that is not served is // answered with no content. func (h handler) byAddress(w http.ResponseWriter, address string, present bool, argument string, answer func(http.ResponseWriter, int, string)) { if !present { missing(w, argument) return } domain := strings.ToLower(address[strings.LastIndex(address, "@")+1:]) for i, d := range h.domains { if strings.Contains(address, "@") && d == domain { answer(w, i, d) return } } reply(w, http.StatusNoContent, htmlType, "") } // thunderbird is Mozilla's autoconfiguration, config-v1.1. The provider id is automx2's: the provider rows were // numbered 1, 11, 21 ... by add-domains. func (h handler) thunderbird(w http.ResponseWriter, i int, domain string) { d := html.EscapeString(domain) server := func(kind, host string, port int) string { tag := map[string]string{"imap": "incomingServer", "smtp": "outgoingServer"}[kind] return fmt.Sprintf(`<%s type="%s">%s%dSTARTTLS`+ `%%EMAILADDRESS%%password-cleartext`, tag, kind, html.EscapeString(host), port, tag) } body := fmt.Sprintf(`%s`+ `%s%s%s%s`, 1+10*i, d, d, d, server("imap", h.imapHost, h.imapPort), server("smtp", h.smtpHost, h.smtpPort)) reply(w, http.StatusOK, xmlType, body) } // apple and outlook answer a served domain as automx2 did: with an error (DIFFERENCES, issue 1691). They are // kept as captured here, so the move to a Go program changes the answer only where clause 9 says. func (h handler) apple(w http.ResponseWriter, _ int, _ string) { reply(w, http.StatusBadRequest, htmlType, badRequest) } // redirectToSlash is Werkzeug's strict-slash redirect, to the name the client used. func (h handler) redirectToSlash(w http.ResponseWriter, r *http.Request) { scheme := "http" if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" { scheme = p } host := r.Host if f := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); f != "" { host = f } target := scheme + "://" + host + "/mobileconfig/" if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } w.Header().Set("Location", target) e := html.EscapeString(target) reply(w, http.StatusPermanentRedirect, htmlType, fmt.Sprintf(` Redirecting...

Redirecting...

You should be redirected automatically to the target URL: %s. If not, click the link. `, e, e)) } // outlook reads the request's EMailAddress anywhere in the document, whatever the namespace. A body that is not // one XML document is automx2's 500. func (h handler) outlook(w http.ResponseWriter, r *http.Request) { dec := xml.NewDecoder(io.LimitReader(r.Body, 1<<20)) var address string var found, elements, inAddress = false, 0, false for { tok, err := dec.Token() if err == io.EOF { break } if err != nil { reply(w, http.StatusInternalServerError, htmlType, internalError) return } switch t := tok.(type) { case xml.StartElement: elements++ if t.Name.Local == "EMailAddress" && !found { inAddress = true } case xml.CharData: if inAddress { address += string(t) } case xml.EndElement: if inAddress && t.Name.Local == "EMailAddress" { inAddress, found = false, true } } } if elements == 0 { reply(w, http.StatusInternalServerError, htmlType, internalError) return } h.byAddress(w, strings.TrimSpace(address), found, "EMailAddress", func(w http.ResponseWriter, _ int, _ string) { reply(w, http.StatusInternalServerError, htmlType, internalError) }) } // run is the program: configuration from the environment, then serving. A configuration that cannot be read is // said, with the variables' names, and the exit code is not zero. func run(getenv func(string) string, out io.Writer, serve func(*http.Server) error) int { c, err := configFrom(getenv) if err != nil { fmt.Fprintf(out, "automx: %v\n", err) return 1 } srv := &http.Server{ Addr: ":" + strconv.Itoa(c.listen), Handler: newHandler(c), ReadHeaderTimeout: 10 * time.Second, } if err := serve(srv); err != nil { fmt.Fprintf(out, "automx: %v\n", err) return 1 } return 0 } func main() { os.Exit(run(os.Getenv, os.Stderr, func(s *http.Server) error { return s.ListenAndServe() })) }