1package http
2
3import (
4 "encoding/xml"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "strings"
9
10 "github.com/prometheus/client_golang/prometheus"
11 "github.com/prometheus/client_golang/prometheus/promauto"
12 "rsc.io/qr"
13
14 "github.com/mjl-/mox/admin"
15 "github.com/mjl-/mox/dns"
16 "github.com/mjl-/mox/smtp"
17)
18
19var (
20 metricAutoconf = promauto.NewCounterVec(
21 prometheus.CounterOpts{
22 Name: "mox_autoconf_request_total",
23 Help: "Number of autoconf requests.",
24 },
25 []string{"domain"},
26 )
27 metricAutodiscover = promauto.NewCounterVec(
28 prometheus.CounterOpts{
29 Name: "mox_autodiscover_request_total",
30 Help: "Number of autodiscover requests.",
31 },
32 []string{"domain"},
33 )
34)
35
36// Autoconfiguration/Autodiscovery:
37//
38// - Thunderbird will request an "autoconfig" xml file.
39// - Microsoft tools will request an "autodiscovery" xml file.
40// - In my tests on an internal domain, iOS mail only talks to Apple servers, then
41// does not attempt autoconfiguration. Possibly due to them being private DNS
42// names. Apple software can be provisioned with "mobileconfig" profile files,
43// which users can download after logging in.
44//
45// DNS records seem optional, but autoconfig.<domain> and autodiscover.<domain>
46// (both CNAME or A) are useful, and so is SRV _autodiscovery._tcp.<domain> 0 0 443
47// autodiscover.<domain> (or just <hostname> directly).
48//
49// Autoconf/discovery only works with valid TLS certificates, not with self-signed
50// certs. So use it on public endpoints with certs signed by common CA's, or run
51// your own (internal) CA and import the CA cert on your devices.
52//
53// Also see https://roll.urown.net/server/mail/autoconfig.html
54
55// Autoconfiguration for Mozilla Thunderbird.
56// User should create a DNS record: autoconfig.<domain> (CNAME or A).
57// See https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
58func autoconfHandle(w http.ResponseWriter, r *http.Request) {
59 log := pkglog.WithContext(r.Context())
60
61 var addrDom string
62 defer func() {
63 metricAutoconf.WithLabelValues(addrDom).Inc()
64 }()
65
66 email := r.FormValue("emailaddress")
67 log.Debug("autoconfig request", slog.String("email", email))
68 var domain dns.Domain
69 if email == "" {
70 email = "%EMAILADDRESS%"
71 // Declare this here rather than using := to avoid shadowing domain from
72 // the outer scope.
73 var err error
74 domain, err = dns.ParseDomain(r.Host)
75 if err != nil {
76 http.Error(w, fmt.Sprintf("400 - bad request - invalid domain: %s", r.Host), http.StatusBadRequest)
77 return
78 }
79 domain.ASCII = strings.TrimPrefix(domain.ASCII, "autoconfig.")
80 domain.Unicode = strings.TrimPrefix(domain.Unicode, "autoconfig.")
81 } else {
82 addr, err := smtp.ParseAddress(email)
83 if err != nil {
84 http.Error(w, "400 - bad request - invalid parameter emailaddress", http.StatusBadRequest)
85 return
86 }
87 domain = addr.Domain
88 }
89
90 socketType := func(tlsMode admin.TLSMode) (string, error) {
91 switch tlsMode {
92 case admin.TLSModeImmediate:
93 return "SSL", nil
94 case admin.TLSModeSTARTTLS:
95 return "STARTTLS", nil
96 case admin.TLSModeNone:
97 return "plain", nil
98 default:
99 return "", fmt.Errorf("unknown tls mode %v", tlsMode)
100 }
101 }
102
103 var imapTLS, submissionTLS string
104 config, err := admin.ClientConfigDomain(domain)
105 if err == nil {
106 imapTLS, err = socketType(config.IMAP.TLSMode)
107 }
108 if err == nil {
109 submissionTLS, err = socketType(config.Submission.TLSMode)
110 }
111 if err != nil {
112 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
113 return
114 }
115
116 // Thunderbird doesn't seem to allow U-labels, always return ASCII names.
117 var resp autoconfigResponse
118 resp.Version = "1.1"
119 resp.EmailProvider.ID = domain.ASCII
120 resp.EmailProvider.Domain = domain.ASCII
121 resp.EmailProvider.DisplayName = email
122 resp.EmailProvider.DisplayShortName = domain.ASCII
123
124 // todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then.
125 // todo: let user configure they prefer or require tls client auth and specify "TLS-client-cert"
126
127 incoming := incomingServer{
128 "imap",
129 config.IMAP.Host.ASCII,
130 config.IMAP.Port,
131 imapTLS,
132 email,
133 "password-encrypted",
134 }
135 resp.EmailProvider.IncomingServers = append(resp.EmailProvider.IncomingServers, incoming)
136 if config.IMAP.EnabledOnHTTPS {
137 tlsMode, _ := socketType(admin.TLSModeImmediate)
138 incomingALPN := incomingServer{
139 "imap",
140 config.IMAP.Host.ASCII,
141 443,
142 tlsMode,
143 email,
144 "password-encrypted",
145 }
146 resp.EmailProvider.IncomingServers = append(resp.EmailProvider.IncomingServers, incomingALPN)
147 }
148
149 outgoing := outgoingServer{
150 "smtp",
151 config.Submission.Host.ASCII,
152 config.Submission.Port,
153 submissionTLS,
154 email,
155 "password-encrypted",
156 }
157 resp.EmailProvider.OutgoingServers = append(resp.EmailProvider.OutgoingServers, outgoing)
158 if config.Submission.EnabledOnHTTPS {
159 tlsMode, _ := socketType(admin.TLSModeImmediate)
160 outgoingALPN := outgoingServer{
161 "smtp",
162 config.Submission.Host.ASCII,
163 443,
164 tlsMode,
165 email,
166 "password-encrypted",
167 }
168 resp.EmailProvider.OutgoingServers = append(resp.EmailProvider.OutgoingServers, outgoingALPN)
169 }
170
171 // todo: should we put the email address in the URL?
172 resp.ClientConfigUpdate.URL = fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml", domain.ASCII)
173
174 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
175 enc := xml.NewEncoder(w)
176 enc.Indent("", "\t")
177 fmt.Fprint(w, xml.Header)
178 if err := enc.Encode(resp); err != nil {
179 log.Errorx("marshal autoconfig response", err)
180 }
181}
182
183// Autodiscover from Microsoft, also used by Thunderbird.
184// User should create a DNS record: _autodiscover._tcp.<domain> SRV 0 0 443 <hostname>
185//
186// In practice, autodiscover does not seem to work wit microsoft clients. A
187// connectivity test tool for outlook is available on
188// https://testconnectivity.microsoft.com/, it has an option to do "Autodiscover to
189// detect server settings". Incoming TLS connections are all failing, with various
190// errors.
191//
192// Thunderbird does understand autodiscover.
193func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
194 log := pkglog.WithContext(r.Context())
195
196 var addrDom string
197 defer func() {
198 metricAutodiscover.WithLabelValues(addrDom).Inc()
199 }()
200
201 if r.Method != "POST" {
202 http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
203 return
204 }
205
206 var req autodiscoverRequest
207 if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
208 http.Error(w, "400 - bad request - parsing autodiscover request: "+err.Error(), http.StatusMethodNotAllowed)
209 return
210 }
211
212 log.Debug("autodiscover request", slog.String("email", req.Request.EmailAddress))
213
214 addr, err := smtp.ParseAddress(req.Request.EmailAddress)
215 if err != nil {
216 http.Error(w, "400 - bad request - invalid parameter emailaddress", http.StatusBadRequest)
217 return
218 }
219
220 // tlsmode returns the "ssl" and "encryption" fields.
221 tlsmode := func(tlsMode admin.TLSMode) (string, string, error) {
222 switch tlsMode {
223 case admin.TLSModeImmediate:
224 return "on", "TLS", nil
225 case admin.TLSModeSTARTTLS:
226 return "on", "", nil
227 case admin.TLSModeNone:
228 return "off", "", nil
229 default:
230 return "", "", fmt.Errorf("unknown tls mode %v", tlsMode)
231 }
232 }
233
234 var imapSSL, imapEncryption string
235 var submissionSSL, submissionEncryption string
236 config, err := admin.ClientConfigDomain(addr.Domain)
237 if err == nil {
238 imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode)
239 }
240 if err == nil {
241 submissionSSL, submissionEncryption, err = tlsmode(config.Submission.TLSMode)
242 }
243 if err != nil {
244 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
245 return
246 }
247
248 // The docs are generated and fragmented in many tiny pages, hard to follow.
249 // High-level starting point, https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/78530279-d042-4eb0-a1f4-03b18143cd19
250 // Request: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/2096fab2-9c3c-40b9-b123-edf6e8d55a9b
251 // Response, protocol: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/f4238db6-a983-435c-807a-b4b4a624c65b
252 // It appears autodiscover does not allow specifying SCRAM-SHA-256 as
253 // authentication method, or any authentication method that real clients actually
254 // use. See
255 // https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
256
257 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
258
259 // todo: let user configure they prefer or require tls client auth and add "AuthPackage" with value "certificate" to Protocol? see https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
260
261 resp := autodiscoverResponse{}
262 resp.XMLName.Local = "Autodiscover"
263 resp.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"
264 resp.Response.XMLName.Local = "Response"
265 resp.Response.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"
266 resp.Response.Account = autodiscoverAccount{
267 AccountType: "email",
268 Action: "settings",
269 Protocol: []autodiscoverProtocol{
270 {
271 Type: "IMAP",
272 Server: config.IMAP.Host.ASCII,
273 Port: config.IMAP.Port,
274 LoginName: req.Request.EmailAddress,
275 SSL: imapSSL,
276 Encryption: imapEncryption,
277 SPA: "off", // Override default "on", this is Microsofts proprietary authentication protocol.
278 AuthRequired: "on",
279 },
280 {
281 Type: "SMTP",
282 Server: config.Submission.Host.ASCII,
283 Port: config.Submission.Port,
284 LoginName: req.Request.EmailAddress,
285 SSL: submissionSSL,
286 Encryption: submissionEncryption,
287 SPA: "off", // Override default "on", this is Microsofts proprietary authentication protocol.
288 AuthRequired: "on",
289 },
290 },
291 }
292 enc := xml.NewEncoder(w)
293 enc.Indent("", "\t")
294 fmt.Fprint(w, xml.Header)
295 if err := enc.Encode(resp); err != nil {
296 log.Errorx("marshal autodiscover response", err)
297 }
298}
299
300// Thunderbird requests these URLs for autoconfig/autodiscover:
301// https://autoconfig.example.org/mail/config-v1.1.xml?emailaddress=user%40example.org
302// https://autodiscover.example.org/autodiscover/autodiscover.xml
303// https://example.org/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=user%40example.org
304// https://example.org/autodiscover/autodiscover.xml
305type incomingServer struct {
306 Type string `xml:"type,attr"`
307 Hostname string `xml:"hostname"`
308 Port int `xml:"port"`
309 SocketType string `xml:"socketType"`
310 Username string `xml:"username"`
311 Authentication string `xml:"authentication"`
312}
313type outgoingServer struct {
314 Type string `xml:"type,attr"`
315 Hostname string `xml:"hostname"`
316 Port int `xml:"port"`
317 SocketType string `xml:"socketType"`
318 Username string `xml:"username"`
319 Authentication string `xml:"authentication"`
320}
321type autoconfigResponse struct {
322 XMLName xml.Name `xml:"clientConfig"`
323 Version string `xml:"version,attr"`
324
325 EmailProvider struct {
326 ID string `xml:"id,attr"`
327 Domain string `xml:"domain"`
328 DisplayName string `xml:"displayName"`
329 DisplayShortName string `xml:"displayShortName"`
330
331 IncomingServers []incomingServer `xml:"incomingServer"`
332 OutgoingServers []outgoingServer `xml:"outgoingServer"`
333 } `xml:"emailProvider"`
334
335 ClientConfigUpdate struct {
336 URL string `xml:"url,attr"`
337 } `xml:"clientConfigUpdate"`
338}
339
340type autodiscoverRequest struct {
341 XMLName xml.Name `xml:"Autodiscover"`
342 Request struct {
343 EmailAddress string `xml:"EMailAddress"`
344 AcceptableResponseSchema string `xml:"AcceptableResponseSchema"`
345 }
346}
347
348type autodiscoverResponse struct {
349 XMLName xml.Name
350 Response struct {
351 XMLName xml.Name
352 Account autodiscoverAccount
353 }
354}
355
356type autodiscoverAccount struct {
357 AccountType string
358 Action string
359 Protocol []autodiscoverProtocol
360}
361
362type autodiscoverProtocol struct {
363 Type string
364 Server string
365 Port int
366 DirectoryPort int
367 ReferralPort int
368 LoginName string
369 SSL string
370 Encryption string `xml:",omitempty"`
371 SPA string
372 AuthRequired string
373}
374
375// Serve a .mobileconfig file. This endpoint is not a standard place where Apple
376// devices look. We point to it from the account page.
377func mobileconfigHandle(w http.ResponseWriter, r *http.Request) {
378 if r.Method != "GET" {
379 http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
380 return
381 }
382 addresses := r.FormValue("addresses")
383 fullName := r.FormValue("name")
384 var buf []byte
385 var err error
386 if addresses == "" {
387 err = fmt.Errorf("missing/empty field addresses")
388 }
389 l := strings.Split(addresses, ",")
390 if err == nil {
391 buf, err = MobileConfig(l, fullName)
392 }
393 if err != nil {
394 http.Error(w, "400 - bad request - "+err.Error(), http.StatusBadRequest)
395 return
396 }
397 h := w.Header()
398 filename := l[0]
399 filename = strings.ReplaceAll(filename, ".", "-")
400 filename = strings.ReplaceAll(filename, "@", "-at-")
401 filename = "email-account-" + filename + ".mobileconfig"
402 h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
403 w.Write(buf)
404}
405
406// Serve a png file with qrcode with the link to the .mobileconfig file, should be
407// helpful for mobile devices.
408func mobileconfigQRCodeHandle(w http.ResponseWriter, r *http.Request) {
409 if r.Method != "GET" {
410 http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
411 return
412 }
413 if !strings.HasSuffix(r.URL.Path, ".qrcode.png") {
414 http.NotFound(w, r)
415 return
416 }
417
418 // Compose URL, scheme and host are not set.
419 u := *r.URL
420 if r.TLS == nil {
421 u.Scheme = "http"
422 } else {
423 u.Scheme = "https"
424 }
425 u.Host = r.Host
426 u.Path = strings.TrimSuffix(u.Path, ".qrcode.png")
427
428 code, err := qr.Encode(u.String(), qr.L)
429 if err != nil {
430 http.Error(w, "500 - internal server error - generating qr-code: "+err.Error(), http.StatusInternalServerError)
431 return
432 }
433 h := w.Header()
434 h.Set("Content-Type", "image/png")
435 w.Write(code.PNG())
436}
437