mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 08:02:14 +00:00
258 lines
6.5 KiB
Go
258 lines
6.5 KiB
Go
package mailhost
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// clientConfig is the Mozilla autoconfig / Thunderbird ISPDB document.
|
|
type clientConfig struct {
|
|
XMLName xml.Name `xml:"clientConfig"`
|
|
Provider struct {
|
|
Incoming []serverConfig `xml:"incomingServer"`
|
|
Outgoing []serverConfig `xml:"outgoingServer"`
|
|
} `xml:"emailProvider"`
|
|
}
|
|
|
|
type serverConfig struct {
|
|
Type string `xml:"type,attr"`
|
|
Hostname string `xml:"hostname"`
|
|
Port string `xml:"port"`
|
|
SocketType string `xml:"socketType"`
|
|
Auth []string `xml:"authentication"`
|
|
}
|
|
|
|
// autoconfig tries the provider-hosted autoconfig files for domain.
|
|
func (d *Detector) autoconfig(ctx context.Context, domain string) *Settings {
|
|
for _, u := range d.autoconfigURLs(domain) {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if s := d.fetchConfig(ctx, u, domain); s != nil {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ispdb asks Thunderbird's ISPDB about lookup, substituting domain into the answer.
|
|
func (d *Detector) ispdb(ctx context.Context, lookup, domain string) *Settings {
|
|
if ctx.Err() != nil || d.ispdbBase == "" {
|
|
return nil
|
|
}
|
|
return d.fetchConfig(ctx, d.ispdbBase+url.PathEscape(lookup), domain)
|
|
}
|
|
|
|
// fetchConfig downloads and parses one clientConfig; any failure is a miss.
|
|
func (d *Detector) fetchConfig(ctx context.Context, u, domain string) *Settings {
|
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
req.Header.Set("Accept", "application/xml, text/xml")
|
|
resp, err := d.client.Do(req)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
|
if err != nil || len(body) > maxBody {
|
|
return nil
|
|
}
|
|
return parseClientConfig(body, domain)
|
|
}
|
|
|
|
// parseClientConfig picks one IMAP and one SMTP server from a clientConfig
|
|
// document, or nil when it does not offer both over TLS.
|
|
func parseClientConfig(body []byte, domain string) *Settings {
|
|
var cfg clientConfig
|
|
dec := xml.NewDecoder(bytes.NewReader(body))
|
|
dec.Strict = false
|
|
if err := dec.Decode(&cfg); err != nil {
|
|
return nil
|
|
}
|
|
imap, okI := pick(cfg.Provider.Incoming, "imap", domain, imapRank)
|
|
smtp, okS := pick(cfg.Provider.Outgoing, "smtp", domain, smtpRank)
|
|
if !okI || !okS {
|
|
return nil
|
|
}
|
|
return &Settings{SMTP: smtp, IMAP: imap}
|
|
}
|
|
|
|
// imapRank prefers implicit TLS on 993.
|
|
func imapRank(e Endpoint) int {
|
|
switch {
|
|
case e.Security == SecurityTLS && e.Port == 993:
|
|
return 0
|
|
case e.Security == SecurityTLS:
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// smtpRank prefers 587 with STARTTLS, then 465 with implicit TLS.
|
|
func smtpRank(e Endpoint) int {
|
|
switch {
|
|
case e.Security == SecurityStartTLS && e.Port == 587:
|
|
return 0
|
|
case e.Security == SecurityTLS && e.Port == 465:
|
|
return 1
|
|
case e.Security == SecurityTLS:
|
|
return 2
|
|
}
|
|
return 3
|
|
}
|
|
|
|
func pick(servers []serverConfig, kind, domain string, rank func(Endpoint) int) (Endpoint, bool) {
|
|
var best Endpoint
|
|
bestRank := -1
|
|
for _, s := range servers {
|
|
if !strings.EqualFold(strings.TrimSpace(s.Type), kind) || !passwordAuth(s.Auth) {
|
|
continue
|
|
}
|
|
e, ok := endpoint(s, kind, domain)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if r := rank(e); bestRank < 0 || r < bestRank {
|
|
best, bestRank = e, r
|
|
}
|
|
}
|
|
return best, bestRank >= 0
|
|
}
|
|
|
|
// passwordAuth reports whether a server takes a password, true when it does not say.
|
|
func passwordAuth(methods []string) bool {
|
|
if len(methods) == 0 {
|
|
return true
|
|
}
|
|
for _, m := range methods {
|
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
|
case "password-cleartext", "password-encrypted", "plain", "secure":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func endpoint(s serverConfig, kind, domain string) (Endpoint, bool) {
|
|
host := strings.ReplaceAll(strings.TrimSpace(s.Hostname), "%EMAILDOMAIN%", domain)
|
|
host = normHost(host)
|
|
if host == "" || !validHostname(host) || !strings.Contains(host, ".") {
|
|
return Endpoint{}, false
|
|
}
|
|
var sec string
|
|
switch strings.ToUpper(strings.TrimSpace(s.SocketType)) {
|
|
case "SSL", "TLS":
|
|
sec = SecurityTLS
|
|
case "STARTTLS":
|
|
sec = SecurityStartTLS
|
|
default:
|
|
return Endpoint{}, false
|
|
}
|
|
port, err := strconv.Atoi(strings.TrimSpace(s.Port))
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
port = defaultPort(kind, sec)
|
|
}
|
|
return Endpoint{Host: host, Port: port, Security: sec}, true
|
|
}
|
|
|
|
func defaultPort(kind, sec string) int {
|
|
switch {
|
|
case kind == "imap" && sec == SecurityTLS:
|
|
return 993
|
|
case kind == "imap":
|
|
return 143
|
|
case sec == SecurityTLS:
|
|
return 465
|
|
}
|
|
return 587
|
|
}
|
|
|
|
// srvSpec is one RFC 6186 / RFC 8314 service record to try.
|
|
type srvSpec struct {
|
|
service string
|
|
security string
|
|
}
|
|
|
|
var (
|
|
srvSMTP = []srvSpec{{"submission", SecurityStartTLS}, {"submissions", SecurityTLS}}
|
|
srvIMAP = []srvSpec{{"imaps", SecurityTLS}, {"imap", SecurityStartTLS}}
|
|
)
|
|
|
|
// srv reads the RFC 6186 service records. err is set when a lookup failed for
|
|
// a reason other than the record not existing.
|
|
func (d *Detector) srv(ctx context.Context, domain string) (*Settings, error) {
|
|
smtp, errS := d.firstSRV(ctx, domain, srvSMTP)
|
|
if errS != nil && smtp == nil {
|
|
return nil, errS
|
|
}
|
|
if smtp == nil {
|
|
return nil, nil
|
|
}
|
|
imap, errI := d.firstSRV(ctx, domain, srvIMAP)
|
|
if imap == nil {
|
|
return nil, errI
|
|
}
|
|
return &Settings{SMTP: *smtp, IMAP: *imap}, nil
|
|
}
|
|
|
|
func (d *Detector) firstSRV(ctx context.Context, domain string, specs []srvSpec) (*Endpoint, error) {
|
|
var lastErr error
|
|
for _, sp := range specs {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
e, err := d.lookupSRV(ctx, sp, domain)
|
|
if e != nil {
|
|
return e, nil
|
|
}
|
|
if err != nil && !notFound(err) {
|
|
lastErr = err
|
|
}
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
func (d *Detector) lookupSRV(ctx context.Context, sp srvSpec, domain string) (*Endpoint, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, srvTimeout)
|
|
defer cancel()
|
|
_, recs, err := d.resolver.LookupSRV(ctx, sp.service, "tcp", domain)
|
|
// Records come back sorted by priority and weight.
|
|
for _, r := range recs {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
host := normHost(r.Target)
|
|
// A target of "." says the service is not offered (RFC 2782).
|
|
if host == "" {
|
|
return nil, nil
|
|
}
|
|
if !validHostname(host) || r.Port == 0 {
|
|
continue
|
|
}
|
|
return &Endpoint{Host: host, Port: int(r.Port), Security: sp.security}, nil
|
|
}
|
|
if err == nil {
|
|
return nil, nil
|
|
}
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|