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