This commit is contained in:
2026-04-14 16:42:51 +02:00
parent 62c7cde62d
commit 3027130295
2 changed files with 385 additions and 233 deletions
+6 -4
View File
@@ -8,7 +8,7 @@ This repo currently configures mail for `billenius.com` on `Hermes`.
- Thunderbird autoconfig: `autoconfig.billenius.com`
- Outlook-style autodiscover: `autodiscover.billenius.com`
- CalDAV/CardDAV via Radicale: `cal.billenius.com`
- Apple configuration profile: `https://autoconfig.billenius.com/mobileconfig/?emailaddress=love%40billenius.com`
- Apple configuration profile: `https://autoconfig.billenius.com/mobileconfig/?username=love&full_name=Love%20Billenius`
- Webmail via Roundcube: `https://mail.billenius.com`
## Repo Locations
@@ -50,12 +50,12 @@ This repo does not manage the apex website host for `billenius.com`. If the publ
### Apple Mobileconfig
- The canonical Apple profile entrypoint is:
- `https://autoconfig.billenius.com/mobileconfig/?emailaddress=<url-encoded-email>`
- `https://autoconfig.billenius.com/mobileconfig/?username=<mail-username>&full_name=<url-encoded-full-name>`
- Apple profiles are hosted from `autoconfig.billenius.com`
- A small landing page is available at:
- `https://autoconfig.billenius.com/mobileconfig/`
- Account-specific profiles are always available at:
- `https://autoconfig.billenius.com/mobileconfig/<sanitized-email>.mobileconfig`
- `https://autoconfig.billenius.com/mobileconfig/<username>.mobileconfig`
- If there is exactly one configured mail account, the profile is also available at:
- `https://autoconfig.billenius.com/mobileconfig/billenius.mobileconfig`
@@ -68,6 +68,8 @@ The profile configures:
Passwords are not embedded in the profile.
The profile generator accepts a username, not a full email address. The generator appends `@billenius.com` automatically. If the full name is blank, or only contains a first name, the generator defaults the last name to `Billenius`.
## DNS
These records are expected for good client discovery.
@@ -103,7 +105,7 @@ _carddavs._tcp.billenius.com. TXT "path=/"
The preferred setup path on Apple devices is to open the hosted profile in Safari:
```text
https://autoconfig.billenius.com/mobileconfig/?emailaddress=love%40billenius.com
https://autoconfig.billenius.com/mobileconfig/?username=love&full_name=Love%20Billenius
```
Install the profile, then enter the password when prompted.
+275 -125
View File
@@ -13,32 +13,125 @@ lib.mkIf hasMailDiscoveryConfig (
domain = builtins.head cfg.domains;
mobileconfigHost = "autoconfig.${domain}";
radicaleHost = "cal.${domain}";
safeAccountName = email: builtins.replaceStrings [ "@" "+" ] [ "_" "-" ] email;
emailQueryPattern = email:
builtins.replaceStrings [
"\\+"
"@"
] [
"(?:\\+|%2B)"
"(?:@|%40)"
] (lib.escapeRegex email);
mobileconfigPort = 8426;
mkUuid = seed:
let
hash = builtins.hashString "sha256" seed;
in
"${lib.substring 0 8 hash}-${lib.substring 8 4 hash}-${lib.substring 12 4 hash}-${lib.substring 16 4 hash}-${lib.substring 20 12 hash}";
safeLegacyPath = email: builtins.replaceStrings [ "@" "+" ] [ "_" "-" ] email;
mkMobileconfig = email:
accountEntries =
lib.filter (entry: entry.domain == domain) (
lib.mapAttrsToList (
email: _:
let
safeName = safeAccountName email;
profileUuid = mkUuid "${email}-apple-profile";
mailUuid = mkUuid "${email}-apple-mail";
caldavUuid = mkUuid "${email}-apple-caldav";
carddavUuid = mkUuid "${email}-apple-carddav";
parts = lib.splitString "@" email;
username = builtins.head parts;
domainPart = lib.last parts;
in
pkgs.writeText "${safeName}.mobileconfig" ''
<?xml version="1.0" encoding="UTF-8"?>
{
inherit email username;
domain = domainPart;
legacyPath = safeLegacyPath email;
}
) cfg.loginAccounts
);
usernames = map (entry: entry.username) accountEntries;
defaultUsername =
if lib.length accountEntries == 1 then (builtins.head accountEntries).username else null;
generatorConfig = {
inherit defaultUsername domain radicaleHost;
mailHost = cfg.fqdn;
accounts = map (entry: {
inherit (entry) email legacyPath username;
}) accountEntries;
};
mobileconfigGenerator = pkgs.writeTextFile {
name = "billenius-mobileconfig-generator";
destination = "/bin/billenius-mobileconfig-generator";
executable = true;
text = builtins.replaceStrings [ "\n " ] [ "\n" ] ''#!${pkgs.python3}/bin/python3
import json
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from xml.sax.saxutils import escape
CONFIG = json.loads(${builtins.toJSON (builtins.toJSON generatorConfig)})
DOMAIN = CONFIG["domain"]
MAIL_HOST = CONFIG["mailHost"]
RADICALE_HOST = CONFIG["radicaleHost"]
DEFAULT_USERNAME = CONFIG["defaultUsername"]
ACCOUNTS = CONFIG["accounts"]
ACCOUNT_BY_USERNAME = {account["username"]: account for account in ACCOUNTS}
ACCOUNT_BY_PATH = {}
for account in ACCOUNTS:
ACCOUNT_BY_PATH[account["username"]] = account
ACCOUNT_BY_PATH[account["legacyPath"]] = account
def first(values):
if not values:
return ""
return values[0]
def title_from_username(username):
pieces = re.split(r"[._+-]+", username)
words = [piece.capitalize() for piece in pieces if piece]
return " ".join(words) if words else username
def normalize_full_name(username, raw_name):
full_name = " ".join(raw_name.split())
if not full_name:
full_name = title_from_username(username)
if " " not in full_name:
full_name = f"{full_name} Billenius"
return full_name
def resolve_account(params):
username = first(params.get("username")) or first(params.get("user"))
email = first(params.get("emailaddress"))
if username:
username = username.strip()
if "@" in username:
local_part, _, domain_part = username.partition("@")
if domain_part.lower() != DOMAIN.lower():
return None
username = local_part
elif email:
email = email.strip()
local_part, _, domain_part = email.partition("@")
if not domain_part or domain_part.lower() != DOMAIN.lower():
return None
username = local_part
else:
return None
return ACCOUNT_BY_USERNAME.get(username)
def mobileconfig_payload(email, full_name):
identifier = re.sub(r"[^A-Za-z0-9_.-]", "-", email)
def uuid(seed):
import hashlib
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()
return f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}"
values = {
"domain": escape(DOMAIN),
"email": escape(email),
"full_name": escape(full_name),
"identifier": escape(identifier),
"mail_host": escape(MAIL_HOST),
"radicale_host": escape(RADICALE_HOST),
"profile_uuid": uuid(f"{email}-apple-profile"),
"mail_uuid": uuid(f"{email}-apple-mail"),
"caldav_uuid": uuid(f"{email}-apple-caldav"),
"carddav_uuid": uuid(f"{email}-apple-carddav"),
}
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
@@ -46,209 +139,266 @@ lib.mkIf hasMailDiscoveryConfig (
<array>
<dict>
<key>EmailAccountDescription</key>
<string>${domain} Mail</string>
<string>{values["domain"]} Mail</string>
<key>EmailAccountName</key>
<string>${email}</string>
<string>{values["full_name"]}</string>
<key>EmailAccountType</key>
<string>EmailTypeIMAP</string>
<key>EmailAddress</key>
<string>${email}</string>
<string>{values["email"]}</string>
<key>IncomingMailServerAuthentication</key>
<string>EmailAuthPassword</string>
<key>IncomingMailServerHostName</key>
<string>${cfg.fqdn}</string>
<string>{values["mail_host"]}</string>
<key>IncomingMailServerPortNumber</key>
<integer>993</integer>
<key>IncomingMailServerUseSSL</key>
<true/>
<key>IncomingMailServerUsername</key>
<string>${email}</string>
<string>{values["email"]}</string>
<key>OutgoingMailServerAuthentication</key>
<string>EmailAuthPassword</string>
<key>OutgoingMailServerHostName</key>
<string>${cfg.fqdn}</string>
<string>{values["mail_host"]}</string>
<key>OutgoingMailServerPortNumber</key>
<integer>587</integer>
<key>OutgoingMailServerUseSSL</key>
<true/>
<key>OutgoingMailServerUsername</key>
<string>${email}</string>
<string>{values["email"]}</string>
<key>OutgoingPasswordSameAsIncomingPassword</key>
<true/>
<key>PayloadDescription</key>
<string>Configures IMAP and SMTP for ${email}.</string>
<string>Configures IMAP and SMTP for {values["email"]}.</string>
<key>PayloadDisplayName</key>
<string>Mail</string>
<key>PayloadIdentifier</key>
<string>com.billenius.mobileconfig.${safeName}.mail</string>
<string>com.billenius.mobileconfig.{values["identifier"]}.mail</string>
<key>PayloadType</key>
<string>com.apple.mail.managed</string>
<key>PayloadUUID</key>
<string>${mailUuid}</string>
<string>{values["mail_uuid"]}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>CalDAVAccountDescription</key>
<string>${domain} Calendar</string>
<string>{values["domain"]} Calendar</string>
<key>CalDAVHostName</key>
<string>${radicaleHost}</string>
<string>{values["radicale_host"]}</string>
<key>CalDAVPort</key>
<integer>443</integer>
<key>CalDAVUseSSL</key>
<true/>
<key>CalDAVUsername</key>
<string>${email}</string>
<string>{values["email"]}</string>
<key>PayloadDescription</key>
<string>Configures CalDAV for ${email}.</string>
<string>Configures CalDAV for {values["email"]}.</string>
<key>PayloadDisplayName</key>
<string>Calendar</string>
<key>PayloadIdentifier</key>
<string>com.billenius.mobileconfig.${safeName}.caldav</string>
<string>com.billenius.mobileconfig.{values["identifier"]}.caldav</string>
<key>PayloadType</key>
<string>com.apple.caldav.account</string>
<key>PayloadUUID</key>
<string>${caldavUuid}</string>
<string>{values["caldav_uuid"]}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<dict>
<key>CardDAVAccountDescription</key>
<string>${domain} Contacts</string>
<string>{values["domain"]} Contacts</string>
<key>CardDAVHostName</key>
<string>${radicaleHost}</string>
<string>{values["radicale_host"]}</string>
<key>CardDAVPort</key>
<integer>443</integer>
<key>CardDAVUseSSL</key>
<true/>
<key>CardDAVUsername</key>
<string>${email}</string>
<string>{values["email"]}</string>
<key>PayloadDescription</key>
<string>Configures CardDAV for ${email}.</string>
<string>Configures CardDAV for {values["email"]}.</string>
<key>PayloadDisplayName</key>
<string>Contacts</string>
<key>PayloadIdentifier</key>
<string>com.billenius.mobileconfig.${safeName}.carddav</string>
<string>com.billenius.mobileconfig.{values["identifier"]}.carddav</string>
<key>PayloadType</key>
<string>com.apple.carddav.account</string>
<key>PayloadUUID</key>
<string>${carddavUuid}</string>
<string>{values["carddav_uuid"]}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures mail, calendar, and contacts for ${email}.</string>
<string>Configures mail, calendar, and contacts for {values["email"]}.</string>
<key>PayloadDisplayName</key>
<string>${domain} Mail</string>
<string>{values["domain"]} Mail</string>
<key>PayloadIdentifier</key>
<string>com.billenius.mobileconfig.${safeName}</string>
<string>com.billenius.mobileconfig.{values["identifier"]}</string>
<key>PayloadOrganization</key>
<string>${domain}</string>
<string>{values["domain"]}</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>${profileUuid}</string>
<string>{values["profile_uuid"]}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
'';
"""
mobileconfigProfiles = lib.mapAttrs (
email: _: {
inherit email;
safeName = safeAccountName email;
profile = mkMobileconfig email;
}
) cfg.loginAccounts;
mobileconfigLandingPage = pkgs.writeText "mobileconfig-index.html" ''
<!doctype html>
def landing_page():
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${domain} Apple Profile</title>
<title>{escape(DOMAIN)} Apple Profile</title>
</head>
<body>
<h1>${domain} Apple Profile</h1>
<p>Open this page in Safari on iPhone, iPad, or macOS and enter your email address to download the configuration profile.</p>
<h1>{escape(DOMAIN)} Apple Profile</h1>
<p>Open this page in Safari on iPhone, iPad, or macOS. Enter your username and optionally your full name to download the configuration profile.</p>
<form method="get" action="/mobileconfig/">
<label for="emailaddress">Email address</label>
<input id="emailaddress" name="emailaddress" type="email" autocomplete="email" required>
<div>
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" pattern="[A-Za-z0-9._+-]+" required>
<span>@{escape(DOMAIN)}</span>
</div>
<div>
<label for="full_name">Full name</label>
<input id="full_name" name="full_name" type="text" autocomplete="name" placeholder="Love Billenius">
</div>
<button type="submit">Download profile</button>
</form>
<p>If you leave the full name blank, or only enter a first name, the profile defaults the last name to Billenius.</p>
<p>The profile configures IMAP, SMTP, CalDAV, and CardDAV. You will still be asked for your password during installation.</p>
</body>
</html>
'';
"""
mobileconfigRedirectConfig =
lib.concatStrings (
lib.mapAttrsToList (
email: profile: ''
if ($arg_emailaddress ~* "^${emailQueryPattern email}$") {
return 302 /mobileconfig/${profile.safeName}.mobileconfig;
}
''
) mobileconfigProfiles
);
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return
mobileconfigLocations = lib.listToAttrs (
[
{
name = "= /mobileconfig";
value.return = "308 /mobileconfig/";
}
{
name = "= /mobileconfig/";
value.extraConfig = ''
${mobileconfigRedirectConfig}
return 302 /mobileconfig/index.html;
'';
}
{
name = "= /mobileconfig/index.html";
value.extraConfig = ''
default_type text/html;
alias ${mobileconfigLandingPage};
'';
}
]
++ lib.mapAttrsToList (
_: profile: {
name = "= /mobileconfig/${profile.safeName}.mobileconfig";
value.extraConfig = ''
default_type application/x-apple-aspen-config;
add_header Content-Disposition 'attachment; filename="${profile.safeName}.mobileconfig"' always;
alias ${profile.profile};
'';
}
) mobileconfigProfiles
);
def send_html(self, body, status=200):
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
defaultMobileconfigLocation =
let
accounts = lib.attrNames mobileconfigProfiles;
in
if lib.length accounts == 1 then
let
profile = mobileconfigProfiles.${builtins.head accounts};
def send_text(self, body, status=400):
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def send_profile(self, account, full_name, filename_base):
payload = mobileconfig_payload(account["email"], full_name).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/x-apple-aspen-config")
self.send_header("Content-Disposition", f'attachment; filename="{filename_base}.mobileconfig"')
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
if parsed.path == "/mobileconfig":
location = "/mobileconfig/"
if parsed.query:
location = f"{location}?{parsed.query}"
self.send_response(308)
self.send_header("Location", location)
self.end_headers()
return
if parsed.path in ("/mobileconfig/", "/mobileconfig/index.html"):
account = resolve_account(params)
if account is None:
has_query = any(first(params.get(key)) for key in ("username", "user", "emailaddress"))
if has_query:
self.send_text("Unknown account", status=404)
else:
self.send_html(landing_page())
return
full_name = normalize_full_name(
account["username"],
first(params.get("full_name")) or first(params.get("name")),
)
self.send_profile(account, full_name, account["username"])
return
if parsed.path == "/mobileconfig/billenius.mobileconfig":
if DEFAULT_USERNAME is None:
self.send_text("No default account", status=404)
return
account = ACCOUNT_BY_USERNAME[DEFAULT_USERNAME]
full_name = normalize_full_name(account["username"], "")
self.send_profile(account, full_name, "billenius")
return
if parsed.path.startswith("/mobileconfig/") and parsed.path.endswith(".mobileconfig"):
basename = parsed.path[len("/mobileconfig/"):-len(".mobileconfig")]
account = ACCOUNT_BY_PATH.get(basename)
if account is None:
self.send_text("Unknown account", status=404)
return
full_name = normalize_full_name(
account["username"],
first(params.get("full_name")) or first(params.get("name")),
)
self.send_profile(account, full_name, basename)
return
self.send_text("Not found", status=404)
ThreadingHTTPServer(("127.0.0.1", ${toString mobileconfigPort}), Handler).serve_forever()
'';
};
in
{
"= /mobileconfig/billenius.mobileconfig".extraConfig = ''
default_type application/x-apple-aspen-config;
add_header Content-Disposition 'attachment; filename="billenius.mobileconfig"' always;
alias ${profile.profile};
'';
}
else
{ };
in
assertions = [
{
services.nginx.virtualHosts.${mobileconfigHost}.locations = mobileconfigLocations // defaultMobileconfigLocation;
assertion = lib.length usernames == lib.length (lib.unique usernames);
message = "Mail mobileconfig usernames must be unique within ${domain}.";
}
];
systemd.services.mobileconfig-generator = {
description = "Apple mobileconfig generator";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
ExecStart = "${mobileconfigGenerator}/bin/billenius-mobileconfig-generator";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
Restart = "always";
};
};
services.nginx.virtualHosts.${mobileconfigHost}.locations = {
"= /mobileconfig".return = "308 /mobileconfig/$is_args$args";
"/mobileconfig/" = {
proxyPass = "http://127.0.0.1:${toString mobileconfigPort}";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
};
}
)