checker-email-autoconfig/checker/parse.go

202 lines
6.3 KiB
Go

package checker
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
)
// ── Thunderbird autoconfig (clientConfig) ────────────────────────────────────
type rawClientConfig struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
EmailProvider rawEmailProvider `xml:"emailProvider"`
}
type rawEmailProvider struct {
ID string `xml:"id,attr"`
DisplayName string `xml:"displayName"`
ShortName string `xml:"displayShortName"`
Domains []string `xml:"domain"`
Incoming []rawServer `xml:"incomingServer"`
Outgoing []rawServer `xml:"outgoingServer"`
AddressBook []rawDav `xml:"addressBook"`
Calendar []rawDav `xml:"calendar"`
WebMail *rawWebMail `xml:"webMail"`
Documentation []rawDocuments `xml:"documentation"`
}
type rawServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port string `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
}
type rawDav struct {
Type string `xml:"type,attr"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
ServerURL string `xml:"serverURL"`
}
type rawWebMail struct {
LoginPage struct {
URL string `xml:"url,attr"`
} `xml:"loginPage"`
}
type rawDocuments struct {
URL string `xml:"url,attr"`
Descr string `xml:"descr"`
}
// parseClientConfig decodes a clientConfig document.
func parseClientConfig(body []byte) (*ClientConfig, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
// Cheap reject before invoking the XML decoder.
if !bytes.Contains(body, []byte("<clientConfig")) {
return nil, fmt.Errorf("not a clientConfig document")
}
var raw rawClientConfig
if err := xml.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
if len(raw.EmailProvider.Incoming) == 0 && len(raw.EmailProvider.Outgoing) == 0 {
return nil, fmt.Errorf("no incoming/outgoing server defined")
}
cfg := &ClientConfig{
Version: raw.Version,
EmailProviderID: raw.EmailProvider.ID,
DisplayName: raw.EmailProvider.DisplayName,
ShortName: raw.EmailProvider.ShortName,
Domains: raw.EmailProvider.Domains,
}
for _, s := range raw.EmailProvider.Incoming {
cfg.Incoming = append(cfg.Incoming, convertServer(s))
}
for _, s := range raw.EmailProvider.Outgoing {
cfg.Outgoing = append(cfg.Outgoing, convertServer(s))
}
for _, d := range raw.EmailProvider.AddressBook {
cfg.AddressBook = append(cfg.AddressBook, convertDav(d))
}
for _, d := range raw.EmailProvider.Calendar {
cfg.Calendar = append(cfg.Calendar, convertDav(d))
}
if raw.EmailProvider.WebMail != nil && raw.EmailProvider.WebMail.LoginPage.URL != "" {
cfg.WebMail = &WebMail{LoginPage: raw.EmailProvider.WebMail.LoginPage.URL}
}
for _, d := range raw.EmailProvider.Documentation {
cfg.Documentation = append(cfg.Documentation, Documentation{URL: d.URL, Descr: d.Descr})
}
return cfg, nil
}
func convertDav(d rawDav) DavServer {
return DavServer{
Type: d.Type,
Username: d.Username,
Authentication: d.Authentication,
ServerURL: d.ServerURL,
}
}
func convertServer(s rawServer) ServerConfig {
port, _ := strconv.Atoi(strings.TrimSpace(s.Port))
return ServerConfig{
Type: strings.ToLower(strings.TrimSpace(s.Type)),
Hostname: strings.TrimSpace(s.Hostname),
Port: port,
SocketType: strings.TrimSpace(s.SocketType),
Username: strings.TrimSpace(s.Username),
Authentication: strings.TrimSpace(s.Authentication),
}
}
// ── Microsoft Autodiscover POX response ──────────────────────────────────────
type rawAutodiscover struct {
XMLName xml.Name `xml:"Autodiscover"`
Response rawADResponse `xml:"Response"`
}
type rawADResponse struct {
User rawADUser `xml:"User"`
Account rawADAccount `xml:"Account"`
}
type rawADUser struct {
DisplayName string `xml:"DisplayName"`
}
type rawADAccount struct {
Action string `xml:"Action"`
RedirectAddr string `xml:"RedirectAddr"`
RedirectURL string `xml:"RedirectUrl"`
Protocols []rawADProto `xml:"Protocol"`
}
type rawADProto struct {
Type string `xml:"Type"`
Server string `xml:"Server"`
Port string `xml:"Port"`
Encryption string `xml:"Encryption"`
SSL string `xml:"SSL"`
LoginName string `xml:"LoginName"`
DomainRequired string `xml:"DomainRequired"`
AuthRequired string `xml:"AuthRequired"`
}
func parseAutodiscoverResponse(body []byte) (*AutodiscoverResponse, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
if !bytes.Contains(body, []byte("Autodiscover")) {
return nil, fmt.Errorf("not an Autodiscover document")
}
var raw rawAutodiscover
dec := xml.NewDecoder(bytes.NewReader(body))
// Exchange responses are namespaced; we accept any.
dec.Strict = false
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
r := &AutodiscoverResponse{
DisplayName: strings.TrimSpace(raw.Response.User.DisplayName),
RedirectAddr: strings.TrimSpace(raw.Response.Account.RedirectAddr),
RedirectURL: strings.TrimSpace(raw.Response.Account.RedirectURL),
}
for _, p := range raw.Response.Account.Protocols {
port, _ := strconv.Atoi(strings.TrimSpace(p.Port))
r.Protocols = append(r.Protocols, AutodiscoverProtocol{
Type: strings.ToUpper(strings.TrimSpace(p.Type)),
Server: strings.TrimSpace(p.Server),
Port: port,
Encryption: strings.TrimSpace(p.Encryption),
SSL: strings.TrimSpace(p.SSL),
LoginName: strings.TrimSpace(p.LoginName),
DomainRequired: strings.TrimSpace(p.DomainRequired),
AuthRequired: strings.TrimSpace(p.AuthRequired),
})
}
// A bare redirect is valid; accept it.
if len(r.Protocols) == 0 && r.RedirectAddr == "" && r.RedirectURL == "" {
return nil, fmt.Errorf("Autodiscover response has no protocol or redirect")
}
return r, nil
}