{
config,
lib,
pkgs,
...
}:
let
cfg = config.mailserver;
hasMailDiscoveryConfig = cfg.enable && cfg.fqdn != null && cfg.domains != [ ];
in
lib.mkIf hasMailDiscoveryConfig (
let
domain = builtins.head cfg.domains;
mobileconfigHost = "autoconfig.${domain}";
radicaleHost = "cal.${domain}";
mobileconfigPort = 8426;
safeLegacyPath = email: builtins.replaceStrings [ "@" "+" ] [ "_" "-" ] email;
accountEntries =
lib.filter (entry: entry.domain == domain) (
lib.mapAttrsToList (
email: _:
let
parts = lib.splitString "@" email;
username = builtins.head parts;
domainPart = lib.last parts;
in
{
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"""
Open this page in Safari on iPhone, iPad, or macOS. Enter your username and optionally your full name to download the configuration profile.
If you leave the full name blank, or only enter a first name, the profile defaults the last name to Billenius.
The profile configures IMAP, SMTP, CalDAV, and CardDAV. You will still be asked for your password during installation.
""" class Handler(BaseHTTPRequestHandler): def log_message(self, format, *args): return 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) 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 { assertions = [ { 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; ''; }; }; } )