{ 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""" PayloadContent EmailAccountDescription {values["domain"]} Mail EmailAccountName {values["full_name"]} EmailAccountType EmailTypeIMAP EmailAddress {values["email"]} IncomingMailServerAuthentication EmailAuthPassword IncomingMailServerHostName {values["mail_host"]} IncomingMailServerPortNumber 993 IncomingMailServerUseSSL IncomingMailServerUsername {values["email"]} OutgoingMailServerAuthentication EmailAuthPassword OutgoingMailServerHostName {values["mail_host"]} OutgoingMailServerPortNumber 587 OutgoingMailServerUseSSL OutgoingMailServerUsername {values["email"]} OutgoingPasswordSameAsIncomingPassword PayloadDescription Configures IMAP and SMTP for {values["email"]}. PayloadDisplayName Mail PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.mail PayloadType com.apple.mail.managed PayloadUUID {values["mail_uuid"]} PayloadVersion 1 CalDAVAccountDescription {values["domain"]} Calendar CalDAVHostName {values["radicale_host"]} CalDAVPort 443 CalDAVUseSSL CalDAVUsername {values["email"]} PayloadDescription Configures CalDAV for {values["email"]}. PayloadDisplayName Calendar PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.caldav PayloadType com.apple.caldav.account PayloadUUID {values["caldav_uuid"]} PayloadVersion 1 CardDAVAccountDescription {values["domain"]} Contacts CardDAVHostName {values["radicale_host"]} CardDAVPort 443 CardDAVUseSSL CardDAVUsername {values["email"]} PayloadDescription Configures CardDAV for {values["email"]}. PayloadDisplayName Contacts PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.carddav PayloadType com.apple.carddav.account PayloadUUID {values["carddav_uuid"]} PayloadVersion 1 PayloadDescription Configures mail, calendar, and contacts for {values["email"]}. PayloadDisplayName {values["domain"]} Mail PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]} PayloadOrganization {values["domain"]} PayloadRemovalDisallowed PayloadType Configuration PayloadUUID {values["profile_uuid"]} PayloadVersion 1 """ def landing_page(): return f""" {escape(DOMAIN)} Apple Profile

{escape(DOMAIN)} Apple Profile

Open this page in Safari on iPhone, iPad, or macOS. Enter your email address and optionally your full name to download the configuration profile.

@{escape(DOMAIN)}

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; ''; }; }; } )