Files
mail/http/mobileconfig.go
zeekayandHanzo Dev 15909c7b79 Rename Go module path to github.com/hanzoai/mail
Fork of github.com/mjl-/mox at 5656ca6d1b
(upstream v0.0.15 plus 55 commits).

Rename the module path in go.mod and the import paths in 268 Go files. Also
update the five scripts that "go install" the module (genwebsite.sh,
docker-release.sh, apidiff.sh, test-upgrade.sh, Dockerfile.release), which
would otherwise build upstream rather than this fork.

Add NOTICE recording the upstream project, author, license, and fork point,
and stating which license covers which files: MIT for the mox source and our
changes, MPL-2.0 for publicsuffix/public_suffix_list.txt alone, and the
vendored dependencies' own licenses under licenses/. Embed NOTICE so
"mail licenses" prints the lineage from the running binary. LICENSE.MIT,
LICENSE.MPLv2.0 and the licenses/ tree are preserved verbatim; no code is
relicensed.

The mox command name, config keys, data directory layout and website content
are unchanged. LLM.md records the fork point, the rename, what was left alone,
and the steps to stand the server up.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 23:16:19 -07:00

206 lines
7.0 KiB
Go

package http
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/xml"
"fmt"
"maps"
"slices"
"strings"
"github.com/hanzoai/mail/admin"
"github.com/hanzoai/mail/smtp"
)
// Apple software isn't good at autoconfig/autodiscovery, but it can import a
// device management profile containing account settings.
//
// See https://developer.apple.com/documentation/devicemanagement/mail.
type deviceManagementProfile struct {
XMLName xml.Name `xml:"plist"`
Version string `xml:"version,attr"`
Dict dict `xml:"dict"`
}
type array []dict
type dict map[string]any
// MarshalXML marshals as <dict> with multiple pairs of <key> and a value of various types.
func (m dict) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
// The plist format isn't that easy to generate with Go's xml package, it's leaving
// out reasonable structure, instead just concatenating key/value pairs. Perhaps
// there is a better way?
if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "dict"}}); err != nil {
return err
}
l := slices.Sorted(maps.Keys(m))
for _, k := range l {
tokens := []xml.Token{
xml.StartElement{Name: xml.Name{Local: "key"}},
xml.CharData([]byte(k)),
xml.EndElement{Name: xml.Name{Local: "key"}},
}
for _, t := range tokens {
if err := e.EncodeToken(t); err != nil {
return err
}
}
tokens = nil
switch v := m[k].(type) {
case string:
tokens = []xml.Token{
xml.StartElement{Name: xml.Name{Local: "string"}},
xml.CharData([]byte(v)),
xml.EndElement{Name: xml.Name{Local: "string"}},
}
case int:
tokens = []xml.Token{
xml.StartElement{Name: xml.Name{Local: "integer"}},
xml.CharData(fmt.Appendf(nil, "%d", v)),
xml.EndElement{Name: xml.Name{Local: "integer"}},
}
case bool:
tag := "false"
if v {
tag = "true"
}
tokens = []xml.Token{
xml.StartElement{Name: xml.Name{Local: tag}},
xml.EndElement{Name: xml.Name{Local: tag}},
}
case array:
if err := e.EncodeToken(xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil {
return err
}
for _, d := range v {
if err := d.MarshalXML(e, xml.StartElement{Name: xml.Name{Local: "array"}}); err != nil {
return err
}
}
if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "array"}}); err != nil {
return err
}
default:
return fmt.Errorf("unexpected dict value of type %T", v)
}
for _, t := range tokens {
if err := e.EncodeToken(t); err != nil {
return err
}
}
}
if err := e.EncodeToken(xml.EndElement{Name: xml.Name{Local: "dict"}}); err != nil {
return err
}
return nil
}
// MobileConfig returns a device profile for a macOS Mail email account. The file
// should have a .mobileconfig extension. Opening the file adds it to Profiles in
// System Preferences, where it can be installed. This profile does not contain a
// password because sending opaque files containing passwords around to users seems
// like bad security practice.
//
// Multiple addresses can be passed, the first is used for IMAP/submission login,
// and likely seen as primary account by Apple software.
//
// The config is not signed, so users must ignore warnings about unsigned profiles.
func MobileConfig(addresses []string, fullName string) ([]byte, error) {
if len(addresses) == 0 {
return nil, fmt.Errorf("need at least 1 address")
}
addr, err := smtp.ParseAddress(addresses[0])
if err != nil {
return nil, fmt.Errorf("parsing address: %v", err)
}
config, err := admin.ClientConfigDomain(addr.Domain)
if err != nil {
return nil, fmt.Errorf("getting config for domain: %v", err)
}
// Apple software wants identifiers...
t := strings.Split(addr.Domain.Name(), ".")
slices.Reverse(t)
reverseAddr := strings.Join(t, ".") + "." + addr.Localpart.String()
// Apple software wants UUIDs... We generate them deterministically based on address
// and our code (through key, which we must change if code changes).
const key = "mox0"
uuid := func(prefix string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(prefix + "\n" + "\n" + strings.Join(addresses, ",")))
sum := mac.Sum(nil)
uuid := fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
return uuid
}
uuidConfig := uuid("config")
uuidAccount := uuid("account")
// The "UseSSL" fields are underspecified in Apple's format. They say "If true,
// enables SSL for authentication on the incoming mail server.". I'm assuming they
// want to know if they should start immediately with a handshake, instead of
// starting out plain. There is no way to require STARTTLS though. You could even
// interpret their wording as this field enable authentication through client-side
// TLS certificates, given their "on the incoming mail server", instead of "of the
// incoming mail server".
var w bytes.Buffer
p := deviceManagementProfile{
Version: "1.0",
Dict: dict(map[string]any{
"PayloadDisplayName": fmt.Sprintf("%s email account", addresses[0]),
"PayloadIdentifier": reverseAddr + ".email",
"PayloadType": "Configuration",
"PayloadUUID": uuidConfig,
"PayloadVersion": 1,
"PayloadContent": array{
dict(map[string]any{
"EmailAccountDescription": addresses[0],
"EmailAccountName": fullName,
"EmailAccountType": "EmailTypeIMAP",
// Comma-separated multiple addresses are not documented at Apple, but seem to
// work.
"EmailAddress": strings.Join(addresses, ","),
"IncomingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing..
"IncomingMailServerUsername": addresses[0],
"IncomingMailServerHostName": config.IMAP.Host.ASCII,
"IncomingMailServerPortNumber": config.IMAP.Port,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate,
"OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
"OutgoingMailServerHostName": config.Submission.Host.ASCII,
"OutgoingMailServerPortNumber": config.Submission.Port,
"OutgoingMailServerUsername": addresses[0],
"OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate,
"OutgoingPasswordSameAsIncomingPassword": true,
"PayloadIdentifier": reverseAddr + ".email.account",
"PayloadType": "com.apple.mail.managed",
"PayloadUUID": uuidAccount,
"PayloadVersion": 1,
}),
},
}),
}
if _, err := fmt.Fprint(&w, xml.Header); err != nil {
return nil, err
}
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 {
return nil, err
}
enc := xml.NewEncoder(&w)
enc.Indent("", "\t")
if err := enc.Encode(p); err != nil {
return nil, err
}
if _, err := fmt.Fprintln(&w); err != nil {
return nil, err
}
return w.Bytes(), nil
}