1package http
2
3import (
4 "bytes"
5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/xml"
8 "fmt"
9 "slices"
10 "sort"
11 "strings"
12
13 "golang.org/x/exp/maps"
14
15 "github.com/mjl-/mox/mox-"
16 "github.com/mjl-/mox/smtp"
17)
18
19// Apple software isn't good at autoconfig/autodiscovery, but it can import a
20// device management profile containing account settings.
21//
22// See https://developer.apple.com/documentation/devicemanagement/mail.
23type deviceManagementProfile struct {
24 XMLName xml.Name `xml:"plist"`
25 Version string `xml:"version,attr"`
26 Dict dict `xml:"dict"`
27}
28
29type array []dict
30
31type dict map[string]any
32
33// MarshalXML marshals as <dict> with multiple pairs of <key> and a value of various types.
34func (m dict) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
35 // The plist format isn't that easy to generate with Go's xml package, it's leaving
36 // out reasonable structure, instead just concatenating key/value pairs. Perhaps
37 // there is a better way?
38
39 if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "dict"}}); err != nil {
40 return err
41 }
42 l := maps.Keys(m)
43 sort.Strings(l)
44 for _, k := range l {
45 tokens := []xml.Token{
46 xml.StartElement{Name: xml.Name{Local: "key"}},
47 xml.CharData([]byte(k)),
48 xml.EndElement{Name: xml.Name{Local: "key"}},
49 }
50 for _, t := range tokens {
51 if err := e.EncodeToken(t); err != nil {
52 return err
53 }
54 }
55 tokens = nil
56
57 switch v := m[k].(type) {
58 case string:
59 tokens = []xml.Token{
60 xml.StartElement{Name: xml.Name{Local: "string"}},
61 xml.CharData([]byte(v)),
62 xml.EndElement{Name: xml.Name{Local: "string"}},
63 }
64 case int:
65 tokens = []xml.Token{
66 xml.StartElement{Name: xml.Name{Local: "integer"}},
67 xml.CharData([]byte(fmt.Sprintf("%d", v))),
68 xml.EndElement{Name: xml.Name{Local: "integer"}},
69 }
70 case bool:
71 tag := "false"
72 if v {
73 tag = "true"
74 }
75 tokens = []xml.Token{
76 xml.StartElement{Name: xml.Name{Local: tag}},
77 xml.EndElement{Name: xml.Name{Local: tag}},
78 }
79 case array:
80 if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil {
81 return err
82 }
83 for _, d := range v {
84 if err := d.MarshalXML(e, xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil {
85 return err
86 }
87 }
88 if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "array"}}); err != nil {
89 return err
90 }
91 default:
92 return fmt.Errorf("unexpected dict value of type %T", v)
93 }
94 for _, t := range tokens {
95 if err := e.EncodeToken(t); err != nil {
96 return err
97 }
98 }
99 }
100 if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "dict"}}); err != nil {
101 return err
102 }
103 return nil
104}
105
106// MobileConfig returns a device profile for a macOS Mail email account. The file
107// should have a .mobileconfig extension. Opening the file adds it to Profiles in
108// System Preferences, where it can be installed. This profile does not contain a
109// password because sending opaque files containing passwords around to users seems
110// like bad security practice.
111//
112// Multiple addresses can be passed, the first is used for IMAP/submission login,
113// and likely seen as primary account by Apple software.
114//
115// The config is not signed, so users must ignore warnings about unsigned profiles.
116func MobileConfig(addresses []string, fullName string) ([]byte, error) {
117 if len(addresses) == 0 {
118 return nil, fmt.Errorf("need at least 1 address")
119 }
120 addr, err := smtp.ParseAddress(addresses[0])
121 if err != nil {
122 return nil, fmt.Errorf("parsing address: %v", err)
123 }
124
125 config, err := mox.ClientConfigDomain(addr.Domain)
126 if err != nil {
127 return nil, fmt.Errorf("getting config for domain: %v", err)
128 }
129
130 // Apple software wants identifiers...
131 t := strings.Split(addr.Domain.Name(), ".")
132 slices.Reverse(t)
133 reverseAddr := strings.Join(t, ".") + "." + addr.Localpart.String()
134
135 // Apple software wants UUIDs... We generate them deterministically based on address
136 // and our code (through key, which we must change if code changes).
137 const key = "mox0"
138 uuid := func(prefix string) string {
139 mac := hmac.New(sha256.New, []byte(key))
140 mac.Write([]byte(prefix + "\n" + "\n" + strings.Join(addresses, ",")))
141 sum := mac.Sum(nil)
142 uuid := fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
143 return uuid
144 }
145
146 uuidConfig := uuid("config")
147 uuidAccount := uuid("account")
148
149 // The "UseSSL" fields are underspecified in Apple's format. They say "If true,
150 // enables SSL for authentication on the incoming mail server.". I'm assuming they
151 // want to know if they should start immediately with a handshake, instead of
152 // starting out plain. There is no way to require STARTTLS though. You could even
153 // interpret their wording as this field enable authentication through client-side
154 // TLS certificates, given their "on the incoming mail server", instead of "of the
155 // incoming mail server".
156
157 var w bytes.Buffer
158 p := deviceManagementProfile{
159 Version: "1.0",
160 Dict: dict(map[string]any{
161 "PayloadDisplayName": fmt.Sprintf("%s email account", addresses[0]),
162 "PayloadIdentifier": reverseAddr + ".email",
163 "PayloadType": "Configuration",
164 "PayloadUUID": uuidConfig,
165 "PayloadVersion": 1,
166 "PayloadContent": array{
167 dict(map[string]any{
168 "EmailAccountDescription": addresses[0],
169 "EmailAccountName": fullName,
170 "EmailAccountType": "EmailTypeIMAP",
171 // Comma-separated multiple addresses are not documented at Apple, but seem to
172 // work.
173 "EmailAddress": strings.Join(addresses, ","),
174 "IncomingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing..
175 "IncomingMailServerUsername": addresses[0],
176 "IncomingMailServerHostName": config.IMAP.Host.ASCII,
177 "IncomingMailServerPortNumber": config.IMAP.Port,
178 "IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate,
179 "OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
180 "OutgoingMailServerHostName": config.Submission.Host.ASCII,
181 "OutgoingMailServerPortNumber": config.Submission.Port,
182 "OutgoingMailServerUsername": addresses[0],
183 "OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate,
184 "OutgoingPasswordSameAsIncomingPassword": true,
185 "PayloadIdentifier": reverseAddr + ".email.account",
186 "PayloadType": "com.apple.mail.managed",
187 "PayloadUUID": uuidAccount,
188 "PayloadVersion": 1,
189 }),
190 },
191 }),
192 }
193 if _, err := fmt.Fprint(&w, xml.Header); err != nil {
194 return nil, err
195 }
196 if _, err := fmt.Fprint(&w, "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"); err != nil {
197 return nil, err
198 }
199 enc := xml.NewEncoder(&w)
200 enc.Indent("", "\t")
201 if err := enc.Encode(p); err != nil {
202 return nil, err
203 }
204 if _, err := fmt.Fprintln(&w); err != nil {
205 return nil, err
206 }
207 return w.Bytes(), nil
208}
209