autodiscover.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package email
  2. /*
  3. autodiscover.go
  4. Works out server settings from an email address, the way Thunderbird does:
  5. 1. known consumer domains (gmail.com, outlook.com, icloud.com, …)
  6. 2. MX records pointing at a known host (Google Workspace, Microsoft 365,
  7. iCloud custom domains, Zoho, Fastmail, …)
  8. 3. the Thunderbird ISPDB (autoconfig.thunderbird.net)
  9. 4. the domain's own autoconfig document
  10. 5. RFC 6186 SRV records
  11. 6. probing imap./mail./smtp. host names
  12. Lookups against the user's domain go through the guarded HTTP client so a
  13. crafted domain cannot make ArozOS fetch LAN addresses.
  14. */
  15. import (
  16. "context"
  17. "encoding/xml"
  18. "io"
  19. "net"
  20. "net/http"
  21. "net/url"
  22. "sort"
  23. "strings"
  24. "sync"
  25. "time"
  26. )
  27. // DiscoverResult is the suggested configuration for an address.
  28. type DiscoverResult struct {
  29. Email string `json:"email"`
  30. Provider string `json:"provider"`
  31. Name string `json:"name"`
  32. IMAP ServerConfig `json:"imap"`
  33. SMTP ServerConfig `json:"smtp"`
  34. OAuth string `json:"oauth,omitempty"`
  35. Source string `json:"source"` //preset | mx | ispdb | autoconfig | srv | probe | guess
  36. Verified bool `json:"verified"`
  37. }
  38. // mxLookup and srvLookup are swapped by tests.
  39. var mxLookup = func(ctx context.Context, domain string) ([]*net.MX, error) {
  40. return net.DefaultResolver.LookupMX(ctx, domain)
  41. }
  42. var srvLookup = func(ctx context.Context, service string, domain string) ([]*net.SRV, error) {
  43. _, records, err := net.DefaultResolver.LookupSRV(ctx, service, "tcp", domain)
  44. return records, err
  45. }
  46. // Discover suggests server settings for an address.
  47. func (m *Manager) Discover(ctx context.Context, p Principal, address string) (*DiscoverResult, error) {
  48. address = strings.TrimSpace(address)
  49. domain := domainOf(address)
  50. if domain == "" || strings.ContainsAny(domain, " /\\") {
  51. return nil, errPleaseEnterAddress
  52. }
  53. fromPreset := func(preset Preset, source string) *DiscoverResult {
  54. result := &DiscoverResult{
  55. Email: address, Provider: preset.ID, Name: preset.Name, IMAP: preset.IMAP, SMTP: preset.SMTP,
  56. OAuth: preset.OAuth, Source: source, Verified: true,
  57. }
  58. result.IMAP.Username = usernameFor(preset.UsernameStyle, address)
  59. result.SMTP.Username = usernameFor(preset.UsernameStyle, address)
  60. return result
  61. }
  62. if preset, ok := presetForDomain(domain); ok {
  63. return fromPreset(preset, "preset"), nil
  64. }
  65. lookupCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
  66. defer cancel()
  67. if records, err := mxLookup(lookupCtx, domain); err == nil {
  68. sort.Slice(records, func(i, j int) bool { return records[i].Pref < records[j].Pref })
  69. for _, record := range records {
  70. if preset, ok := presetForMX(record.Host); ok {
  71. return fromPreset(preset, "mx"), nil
  72. }
  73. }
  74. }
  75. if result := m.discoverAutoconfig(ctx, p, address, domain); result != nil {
  76. return result, nil
  77. }
  78. if result := discoverSRV(ctx, address, domain); result != nil {
  79. return result, nil
  80. }
  81. return m.discoverByProbing(ctx, p, address, domain), nil
  82. }
  83. var errPleaseEnterAddress = &discoverError{"please enter a complete email address"}
  84. type discoverError struct{ message string }
  85. func (e *discoverError) Error() string { return e.message }
  86. // Thunderbird autoconfig document (subset).
  87. type autoconfigDocument struct {
  88. Providers []struct {
  89. ID string `xml:"id,attr"`
  90. DisplayName string `xml:"displayName"`
  91. Incoming []autoconfigServer `xml:"incomingServer"`
  92. Outgoing []autoconfigServer `xml:"outgoingServer"`
  93. } `xml:"emailProvider"`
  94. }
  95. type autoconfigServer struct {
  96. Type string `xml:"type,attr"`
  97. Hostname string `xml:"hostname"`
  98. Port int `xml:"port"`
  99. SocketType string `xml:"socketType"`
  100. Username string `xml:"username"`
  101. Authentication []string `xml:"authentication"`
  102. }
  103. func (m *Manager) discoverAutoconfig(ctx context.Context, p Principal, address string, domain string) *DiscoverResult {
  104. type source struct {
  105. url string
  106. guarded bool
  107. name string
  108. }
  109. sources := []source{
  110. {url: "https://autoconfig.thunderbird.net/v1.1/" + url.PathEscape(domain), name: "ispdb"},
  111. {url: "https://autoconfig." + domain + "/mail/config-v1.1.xml?emailaddress=" + url.QueryEscape(address), guarded: true, name: "autoconfig"},
  112. {url: "https://" + domain + "/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=" + url.QueryEscape(address), guarded: true, name: "autoconfig"},
  113. }
  114. results := make([]*DiscoverResult, len(sources))
  115. var wg sync.WaitGroup
  116. for index, src := range sources {
  117. wg.Add(1)
  118. go func(index int, src source) {
  119. defer wg.Done()
  120. client := m.options.HTTPClient
  121. if client == nil {
  122. if src.guarded {
  123. client = guardedHTTPClient(m.allowPrivate(p), 6*time.Second)
  124. } else {
  125. client = &http.Client{Timeout: 6 * time.Second}
  126. }
  127. }
  128. requestCtx, cancel := context.WithTimeout(ctx, 7*time.Second)
  129. defer cancel()
  130. request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, src.url, nil)
  131. if err != nil {
  132. return
  133. }
  134. response, err := client.Do(request)
  135. if err != nil {
  136. return
  137. }
  138. defer response.Body.Close()
  139. if response.StatusCode != http.StatusOK {
  140. return
  141. }
  142. body, err := io.ReadAll(io.LimitReader(response.Body, 256*1024))
  143. if err != nil {
  144. return
  145. }
  146. if result := parseAutoconfig(body, address); result != nil {
  147. result.Source = src.name
  148. results[index] = result
  149. }
  150. }(index, src)
  151. }
  152. wg.Wait()
  153. for _, result := range results {
  154. if result != nil {
  155. return result
  156. }
  157. }
  158. return nil
  159. }
  160. // parseAutoconfig reads a Thunderbird autoconfig XML document.
  161. func parseAutoconfig(body []byte, address string) *DiscoverResult {
  162. document := autoconfigDocument{}
  163. if err := xml.Unmarshal(body, &document); err != nil || len(document.Providers) == 0 {
  164. return nil
  165. }
  166. provider := document.Providers[0]
  167. pick := func(servers []autoconfigServer, wantType string) (ServerConfig, bool) {
  168. best := ServerConfig{}
  169. bestRank := -1
  170. for _, server := range servers {
  171. if !strings.EqualFold(server.Type, wantType) || server.Hostname == "" {
  172. continue
  173. }
  174. security := map[string]string{"ssl": SecuritySSL, "tls": SecuritySSL, "starttls": SecuritySTARTTLS, "plain": SecurityNone}[strings.ToLower(server.SocketType)]
  175. if security == "" {
  176. continue
  177. }
  178. rank := map[string]int{SecuritySSL: 3, SecuritySTARTTLS: 2, SecurityNone: 0}[security]
  179. //Skip servers that only offer OAuth we cannot do with a password
  180. if rank > bestRank {
  181. bestRank = rank
  182. best = ServerConfig{
  183. Host: substituteAutoconfig(server.Hostname, address),
  184. Port: server.Port,
  185. Security: security,
  186. Username: substituteAutoconfig(server.Username, address),
  187. }
  188. }
  189. }
  190. return best, bestRank >= 0
  191. }
  192. imapServer, ok := pick(provider.Incoming, "imap")
  193. if !ok {
  194. return nil
  195. }
  196. smtpServer, ok := pick(provider.Outgoing, "smtp")
  197. if !ok {
  198. return nil
  199. }
  200. if imapServer.Port == 0 {
  201. imapServer.Port = map[string]int{SecuritySSL: 993, SecuritySTARTTLS: 143, SecurityNone: 143}[imapServer.Security]
  202. }
  203. if smtpServer.Port == 0 {
  204. smtpServer.Port = map[string]int{SecuritySSL: 465, SecuritySTARTTLS: 587, SecurityNone: 25}[smtpServer.Security]
  205. }
  206. if imapServer.Username == "" {
  207. imapServer.Username = address
  208. }
  209. if smtpServer.Username == "" {
  210. smtpServer.Username = address
  211. }
  212. name := strings.TrimSpace(provider.DisplayName)
  213. result := &DiscoverResult{
  214. Email: address, Provider: "custom", Name: name, IMAP: imapServer, SMTP: smtpServer, Verified: true,
  215. }
  216. if detected := providerForServer(imapServer.Host); detected != "" {
  217. result.Provider = detected
  218. if preset, ok := PresetByID(detected); ok {
  219. result.OAuth = preset.OAuth
  220. }
  221. }
  222. return result
  223. }
  224. func substituteAutoconfig(value string, address string) string {
  225. local := address
  226. if at := strings.LastIndex(address, "@"); at >= 0 {
  227. local = address[:at]
  228. }
  229. value = strings.ReplaceAll(value, "%EMAILADDRESS%", address)
  230. value = strings.ReplaceAll(value, "%EMAILLOCALPART%", local)
  231. value = strings.ReplaceAll(value, "%EMAILDOMAIN%", domainOf(address))
  232. return strings.TrimSpace(value)
  233. }
  234. // discoverSRV reads RFC 6186 service records.
  235. func discoverSRV(ctx context.Context, address string, domain string) *DiscoverResult {
  236. lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
  237. defer cancel()
  238. first := func(service string) *net.SRV {
  239. records, err := srvLookup(lookupCtx, service, domain)
  240. if err != nil || len(records) == 0 || records[0].Target == "." || records[0].Target == "" {
  241. return nil
  242. }
  243. return records[0]
  244. }
  245. result := &DiscoverResult{Email: address, Provider: "custom", Source: "srv", Verified: true}
  246. if record := first("imaps"); record != nil {
  247. result.IMAP = ServerConfig{Host: strings.TrimSuffix(record.Target, "."), Port: int(record.Port), Security: SecuritySSL, Username: address}
  248. } else if record := first("imap"); record != nil {
  249. result.IMAP = ServerConfig{Host: strings.TrimSuffix(record.Target, "."), Port: int(record.Port), Security: SecuritySTARTTLS, Username: address}
  250. } else {
  251. return nil
  252. }
  253. if record := first("submissions"); record != nil {
  254. result.SMTP = ServerConfig{Host: strings.TrimSuffix(record.Target, "."), Port: int(record.Port), Security: SecuritySSL, Username: address}
  255. } else if record := first("submission"); record != nil {
  256. result.SMTP = ServerConfig{Host: strings.TrimSuffix(record.Target, "."), Port: int(record.Port), Security: SecuritySTARTTLS, Username: address}
  257. } else {
  258. return nil
  259. }
  260. return result
  261. }
  262. // discoverByProbing tries the conventional host names and ports.
  263. func (m *Manager) discoverByProbing(ctx context.Context, p Principal, address string, domain string) *DiscoverResult {
  264. allowPrivate := m.allowPrivate(p)
  265. type candidate struct {
  266. host string
  267. port int
  268. security string
  269. }
  270. probe := func(candidates []candidate) (candidate, bool) {
  271. found := make([]bool, len(candidates))
  272. var wg sync.WaitGroup
  273. for index, item := range candidates {
  274. wg.Add(1)
  275. go func(index int, item candidate) {
  276. defer wg.Done()
  277. probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
  278. defer cancel()
  279. conn, err := dialGuarded(probeCtx, item.host, item.port, allowPrivate)
  280. if err == nil {
  281. conn.Close()
  282. found[index] = true
  283. }
  284. }(index, item)
  285. }
  286. wg.Wait()
  287. for index, ok := range found {
  288. if ok {
  289. return candidates[index], true
  290. }
  291. }
  292. return candidate{}, false
  293. }
  294. imapCandidates := []candidate{
  295. {"imap." + domain, 993, SecuritySSL}, {"mail." + domain, 993, SecuritySSL},
  296. {"imap." + domain, 143, SecuritySTARTTLS}, {"mail." + domain, 143, SecuritySTARTTLS},
  297. }
  298. smtpCandidates := []candidate{
  299. {"smtp." + domain, 465, SecuritySSL}, {"mail." + domain, 465, SecuritySSL},
  300. {"smtp." + domain, 587, SecuritySTARTTLS}, {"mail." + domain, 587, SecuritySTARTTLS},
  301. }
  302. result := &DiscoverResult{Email: address, Provider: "custom", Source: "guess"}
  303. imapFound, imapOK := probe(imapCandidates)
  304. smtpFound, smtpOK := probe(smtpCandidates)
  305. if imapOK {
  306. result.IMAP = ServerConfig{Host: imapFound.host, Port: imapFound.port, Security: imapFound.security, Username: address}
  307. } else {
  308. result.IMAP = ServerConfig{Host: "imap." + domain, Port: 993, Security: SecuritySSL, Username: address}
  309. }
  310. if smtpOK {
  311. result.SMTP = ServerConfig{Host: smtpFound.host, Port: smtpFound.port, Security: smtpFound.security, Username: address}
  312. } else {
  313. result.SMTP = ServerConfig{Host: "smtp." + domain, Port: 465, Security: SecuritySSL, Username: address}
  314. }
  315. if imapOK && smtpOK {
  316. result.Source = "probe"
  317. result.Verified = true
  318. }
  319. return result
  320. }