// 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 = `
The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.
` notAllowed = `The method is not allowed for the requested URL.
` internalError = `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 = `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">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() })) }