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