diff --git a/resources/config.dovecot-postfix.toml b/resources/config.dovecot-postfix.toml new file mode 100644 index 0000000..51a5e8b --- /dev/null +++ b/resources/config.dovecot-postfix.toml @@ -0,0 +1,176 @@ +# =========================================================================== +# Migration proxy: Dovecot + Postfix to Stalwart sample configuration. +# +# This configuration fronts a legacy mail stack (Dovecot serving IMAP, POP3 and +# ManageSieve, Postfix handling SMTP submission) and a new Stalwart server at the +# same time, migrating accounts from the legacy stack to Stalwart one at a time. +# Clients keep talking to a single hostname on the usual ports; the proxy decides +# per account which backend currently holds the mailbox. +# +# The legacy backend speaks the Dovecot and Postfix conventions: it expects the +# real client address through the XCLIENT command (and the IMAP ID command), not +# through a PROXY protocol header. The legacy destination therefore uses +# forwarding = "xclient", while the Stalwart destination uses forwarding = +# "proxy". For XCLIENT to be honoured, Dovecot and Postfix must be told to trust +# the proxy; see the companion file resources/dovecot-postfix-trust.conf. +# +# Inbound mail on port 25 is handed to Stalwart, which performs split delivery: +# mail for a migrated account is delivered locally, and mail for an account still +# on the legacy stack is relayed back to Postfix. +# +# Replace every value marked CHANGE ME and review the rest before starting. +# =========================================================================== + +[server] +hostname = "mail.example.com" # CHANGE ME (public hostname clients use) +shutdown_grace = "30s" +log_level = "info" + +# TLS certificate the proxy presents to clients (the public hostname's cert). +[tls.certificate.default] +cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME +key = "/etc/proxy/tls/privkey.pem" # CHANGE ME +default = true + +[tls.protocols] +min_version = "1.2" + +# --------------------------------------------------------------------------- +# Routing. +# +# default_destination is "legacy" so that accounts not yet migrated resolve to +# the Dovecot/Postfix stack. As each account is migrated, an entry pointing it at +# "stalwart" is added to the mapping file. +# +# smtp_passthrough_destination is "stalwart": port 25 carries no login to route +# on, so it is handed whole to the new server, which performs split delivery and +# relays not-yet-migrated recipients back to Postfix. +# --------------------------------------------------------------------------- +[routing] +default_destination = "legacy" +smtp_passthrough_destination = "stalwart" + +[mapping] +source = "file" +normalize = "lowercase" +positive_ttl = "10m" +negative_ttl = "30s" + +[mapping.file] +# Whitespace-separated "identifier destination" lines. A migrated account gets a +# line pointing it at "stalwart"; everything else falls through to "legacy". For +# example: +# alice@example.com stalwart +path = "/etc/proxy/mappings.tsv" # CHANGE ME + +# --------------------------------------------------------------------------- +# Legacy Dovecot + Postfix backend. Announced to the backend over XCLIENT and the +# IMAP ID command (forwarding = "xclient"), which is what Dovecot and Postfix +# understand. The backend leg uses STARTTLS on the classic plaintext ports. +# +# The legacy server presents a self-signed certificate. Pin it by fingerprint +# with tls_pinned_cert_sha256 (recommended), or, on a trusted internal network, +# set tls_allow_invalid_certs = true. +# --------------------------------------------------------------------------- +[destination.legacy] +host = "legacy.internal.example.com" # CHANGE ME (internal address of the legacy stack) +tls_server_name = "legacy.internal.example.com" +forwarding = "xclient" +# tls_pinned_cert_sha256 = "aa:bb:...:99" # CHANGE ME (legacy cert SHA-256), or: +tls_allow_invalid_certs = true + +[destination.legacy.protocol.imap] +port = 143 +tls = "starttls" +[destination.legacy.protocol.pop3] +port = 110 +tls = "starttls" +[destination.legacy.protocol.submission] +port = 587 +tls = "starttls" +[destination.legacy.protocol.managesieve] +port = 4190 +tls = "starttls" + +# --------------------------------------------------------------------------- +# New Stalwart backend. Announced over the PROXY protocol (forwarding = "proxy", +# proxy_protocol = true); Stalwart is configured to trust the proxy's address. +# Declares an smtp endpoint because it is the pass-through destination for port 25. +# --------------------------------------------------------------------------- +[destination.stalwart] +host = "new.internal.example.com" # CHANGE ME (internal address of the new server) +tls_server_name = "new.internal.example.com" +forwarding = "proxy" +proxy_protocol = true +forwarded = true +# tls_allow_invalid_certs = true # if the new server uses a self-signed cert + +[destination.stalwart.protocol.imap] +port = 993 +tls = "implicit" +[destination.stalwart.protocol.pop3] +port = 995 +tls = "implicit" +[destination.stalwart.protocol.submission] +port = 587 +tls = "starttls" +[destination.stalwart.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.stalwart.protocol.smtp] +port = 25 +tls = "plain" + +# --------------------------------------------------------------------------- +# Client-facing listeners on the familiar ports. Port 25 is a raw pass-through to +# Stalwart and therefore plain (a pass-through listener cannot offer STARTTLS). +# +# Note: Dovecot and Postfix serve no HTTP or JMAP, so no HTTP listener is defined +# here. Once accounts are on Stalwart, JMAP and the web interface are served by +# the new server; an HTTP listener routing to "stalwart" can be added at that +# point if a single hostname for web access is wanted. +# --------------------------------------------------------------------------- +[listener.imaps] +protocol = "imap" +bind = ["0.0.0.0:993", "[::]:993"] +tls = "implicit" +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:143", "[::]:143"] +tls = "starttls" +[listener.pop3s] +protocol = "pop3" +bind = ["0.0.0.0:995", "[::]:995"] +tls = "implicit" +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:110", "[::]:110"] +tls = "starttls" +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:587", "[::]:587"] +tls = "starttls" +[listener.submissions] +protocol = "submission" +bind = ["0.0.0.0:465", "[::]:465"] +tls = "implicit" +[listener.managesieve] +protocol = "managesieve" +bind = ["0.0.0.0:4190", "[::]:4190"] +tls = "starttls" +[listener.smtp] +protocol = "smtp" +bind = ["0.0.0.0:25", "[::]:25"] +tls = "plain" + +# --------------------------------------------------------------------------- +# Management API: mapping changes, cache invalidation and connection control. +# TLS is mandatory. Bind to loopback or a trusted management network only. +# --------------------------------------------------------------------------- +[admin] +bind = "127.0.0.1:9443" +tls = "implicit" +bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME +min_token_len = 32 +lockout_threshold = 5 +lockout_duration = "5m" diff --git a/resources/config.stalwart-upgrade.toml b/resources/config.stalwart-upgrade.toml new file mode 100644 index 0000000..5b1fec4 --- /dev/null +++ b/resources/config.stalwart-upgrade.toml @@ -0,0 +1,252 @@ +# =========================================================================== +# Migration proxy: Stalwart upgrade (old -> new) sample configuration. +# +# This configuration fronts two Stalwart deployments at the same time: an older +# one ("old", typically v0.15 or below) that still holds most accounts, and a +# new one ("new", the latest release) that accounts are migrated onto one by +# one. Clients keep talking to a single hostname on the usual ports; the proxy +# decides per account which deployment currently holds the mailbox. +# +# Before each account is migrated, it resolves to "old". Inbound mail on port 25 +# is always handed to "new", which performs split delivery: mail for an account +# that already lives on "new" is delivered locally, while mail for an account +# still on "old" is relayed there. As each account is moved its mapping is +# flipped to "new". +# +# Replace every value marked CHANGE ME and review the rest before starting. +# =========================================================================== + +[server] +# Public hostname clients connect to. Advertised in protocol greetings. +hostname = "mail.example.org" # CHANGE ME +shutdown_grace = "30s" +log_level = "info" + +# TLS certificate the proxy presents to clients. This is the certificate for +# the public hostname above; it terminates implicit TLS and STARTTLS. +[tls.certificate.default] +cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME +key = "/etc/proxy/tls/privkey.pem" # CHANGE ME +default = true + +[tls.protocols] +min_version = "1.2" + +# --------------------------------------------------------------------------- +# Routing. +# +# default_destination is where accounts with no explicit mapping resolve. While +# the migration is in progress this must be "old", so that accounts that have +# not been moved yet keep reaching their current mailbox. +# +# smtp_passthrough_destination is the fixed target for raw SMTP on port 25 and +# LMTP on port 24. It must be "new", because the new deployment is the one that +# performs split delivery and relays not-yet-migrated recipients back to old. +# --------------------------------------------------------------------------- +[routing] +default_destination = "old" +smtp_passthrough_destination = "new" + +# --------------------------------------------------------------------------- +# Mapping store: identifier -> destination. A migrated account gets a line +# pointing it at "new"; everything else falls through to default_destination. +# The identifier is the login the client presents (the e-mail address, or for +# older servers the bare account name), normalized to lowercase. +# --------------------------------------------------------------------------- +[mapping] +source = "file" +normalize = "lowercase" +positive_ttl = "10m" +negative_ttl = "30s" + +[mapping.file] +# A whitespace-separated file of "identifier destination" lines, for example: +# alice@example.org new +# alice new +# List both the e-mail address and the bare account name when the old server +# allowed logging in with either form. New entries take effect after a mapping +# reload (see the management API) or a cache expiry. +path = "/etc/proxy/mappings.tsv" # CHANGE ME + +# --------------------------------------------------------------------------- +# Destinations. +# +# Both backends are Stalwart and both are configured to accept the PROXY +# protocol on their listeners, so the proxy announces the real client over a +# PROXY v2 header (forwarding = "proxy", proxy_protocol = true) on every mail +# leg, and appends Forwarded / X-Forwarded-For on the HTTP leg (forwarded = +# true). The old server is the single exception: its port 25 must NOT expect a +# PROXY header, because the new server relays to it as an ordinary SMTP client. +# That exception is configured on the old server, not here. +# +# host is the internal address each deployment now listens on. tls_server_name +# is the name presented for SNI and certificate matching on the backend leg. +# If a backend uses a self-signed or internal certificate, either pin it with +# tls_pinned_cert_sha256 or, for a trusted internal network only, uncomment +# tls_allow_invalid_certs. +# --------------------------------------------------------------------------- +[destination.old] +host = "old.internal.example.org" # CHANGE ME +tls_server_name = "old.internal.example.org" +forwarding = "proxy" +proxy_protocol = true +forwarded = true +# tls_allow_invalid_certs = true + +[destination.old.protocol.imap] +port = 993 +tls = "implicit" +[destination.old.protocol.pop3] +port = 995 +tls = "implicit" +[destination.old.protocol.submission] +port = 587 +tls = "starttls" +[destination.old.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.old.protocol.http] +port = 443 +tls = "implicit" + +[destination.new] +host = "new.internal.example.org" # CHANGE ME +tls_server_name = "new.internal.example.org" +forwarding = "proxy" +proxy_protocol = true +forwarded = true +# tls_allow_invalid_certs = true + +[destination.new.protocol.imap] +port = 993 +tls = "implicit" +[destination.new.protocol.pop3] +port = 995 +tls = "implicit" +[destination.new.protocol.submission] +port = 587 +tls = "starttls" +[destination.new.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.new.protocol.http] +port = 443 +tls = "implicit" +# Required because new is the smtp_passthrough_destination (port 25 / LMTP). +[destination.new.protocol.smtp] +port = 25 +tls = "plain" + +# --------------------------------------------------------------------------- +# HTTP routing. Rules are evaluated top to bottom and the first match wins. +# +# The web interfaces and login endpoints differ between the two versions, so a +# handful of paths that are unique to each version are pinned to the backend +# that serves them. The OAuth token exchange is shared, so it is split by the +# client_id present in the request body. Everything else, including JMAP, +# WebDAV and the JMAP session resource, carries credentials and is routed by +# the account those credentials identify, falling back to the default +# destination when no account can be determined. +# --------------------------------------------------------------------------- + +# Endpoints served only by the old deployment. +[[http.route]] +match = "/api/oauth" +destination = "old" +[[http.route]] +match = "/authorize" +destination = "old" +[[http.route]] +match = "/authorize/**" +destination = "old" + +# Endpoints served only by the new deployment. +[[http.route]] +match = "/api/auth" +destination = "new" +[[http.route]] +match = "/api/discover/**" +destination = "new" +[[http.route]] +match = "/admin/**" +destination = "new" +[[http.route]] +match = "/account/**" +destination = "new" +[[http.route]] +match = "/login" +destination = "new" +[[http.route]] +match = "/device" +destination = "new" + +# OAuth token exchange is shared; route it by the web client that requested it. +# Add matching mapping entries: webadmin old and stalwart-webui new +[[http.route]] +match = "/auth/token" +extract = { from = "body", regex = "client_id=([^&]+)" } +fallback = "old" + +# Everything else: route by the authenticated account, fall back to default. +[[http.route]] +match = "/**" +extract = { from = "authorization" } +fallback = "default" + +# --------------------------------------------------------------------------- +# Listeners: the public ports clients connect to. These take over the ports the +# old server used to answer on directly. +# --------------------------------------------------------------------------- +[listener.imaps] +protocol = "imap" +bind = ["0.0.0.0:993", "[::]:993"] +tls = "implicit" +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:143", "[::]:143"] +tls = "starttls" +[listener.pop3s] +protocol = "pop3" +bind = ["0.0.0.0:995", "[::]:995"] +tls = "implicit" +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:110", "[::]:110"] +tls = "starttls" +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:587", "[::]:587"] +tls = "starttls" +[listener.submissions] +protocol = "submission" +bind = ["0.0.0.0:465", "[::]:465"] +tls = "implicit" +[listener.managesieve] +protocol = "managesieve" +bind = ["0.0.0.0:4190", "[::]:4190"] +tls = "starttls" +[listener.https] +protocol = "http" +bind = ["0.0.0.0:443", "[::]:443"] +tls = "implicit" +# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather +# than trusting them; the proxy sets its own based on the real peer. +forwarded = "off" +# Raw SMTP. Pass-through to new (smtp_passthrough_destination). Must be plain: +# a pass-through listener cannot offer STARTTLS. +[listener.smtp] +protocol = "smtp" +bind = ["0.0.0.0:25", "[::]:25"] +tls = "plain" + +# --------------------------------------------------------------------------- +# Management API: mapping changes, cache invalidation and connection control. +# TLS is mandatory. Bind it to loopback or a trusted management network only. +# --------------------------------------------------------------------------- +[admin] +bind = "127.0.0.1:9443" +tls = "implicit" +bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME +min_token_len = 32 +lockout_threshold = 5 +lockout_duration = "5m" diff --git a/resources/dovecot-postfix-trust.conf b/resources/dovecot-postfix-trust.conf new file mode 100644 index 0000000..001218d --- /dev/null +++ b/resources/dovecot-postfix-trust.conf @@ -0,0 +1,65 @@ +# =========================================================================== +# Legacy backend trust: Dovecot + Postfix settings for the migration proxy. +# +# These are the changes the legacy Dovecot and Postfix stack needs so that the +# migration proxy can front it. They do two things: +# +# 1. Trust the proxy to announce the real client address. The proxy forwards it +# over the XCLIENT command (Postfix submission and Dovecot ManageSieve/POP3) +# and the IMAP ID command, instead of a PROXY protocol header. Without this +# trust the backend records the proxy's address as the client; logins still +# work, but the real client address is lost. +# +# 2. Provide a master user, so the migration tool (Vandelay) can read any +# mailbox over IMAP with a single administrative credential rather than each +# user's password. +# +# Replace the address 203.0.113.10 with the proxy's actual address. Restrict the +# trust to the proxy, not to the whole network, so that arbitrary hosts cannot +# spoof a client address. +# =========================================================================== + +# --------------------------------------------------------------------------- +# Dovecot: /etc/dovecot/dovecot.conf (or a file under /etc/dovecot/conf.d) +# --------------------------------------------------------------------------- + +# Trust the proxy to forward the real client address via XCLIENT and the IMAP ID +# command. List the proxy's address only. +login_trusted_networks = 203.0.113.10/32 + +# Master user support, used by Vandelay to read any mailbox during migration. The +# login presented is "%" (here the separator is "%"), with the +# master user's password. For example, reading alice's mailbox uses the login +# "alice@example.com%vmadmin" and vmadmin's password. +auth_master_user_separator = % + +passdb { + driver = passwd-file + master = yes + args = scheme=PLAIN username_format=%u /etc/dovecot/master-users +} + +# /etc/dovecot/master-users (one line; use a real password hash in production, +# for example one produced by "doveadm pw -s SHA512-CRYPT"): +# vmadmin:{PLAIN}ChangeThisMasterPassword + +# --------------------------------------------------------------------------- +# Postfix: apply with postconf, or add to /etc/postfix/main.cf +# --------------------------------------------------------------------------- + +# Trust the proxy to announce the real client over XCLIENT on the SMTP and +# submission services. List the proxy's address only. +# +# postconf -e "smtpd_authorized_xclient_hosts=203.0.113.10/32" +# +# main.cf equivalent: +# smtpd_authorized_xclient_hosts = 203.0.113.10/32 + +# After changing either server, reload it: +# doveadm reload # Dovecot +# postfix reload # Postfix +# +# To confirm XCLIENT is being honoured, sign in through the proxy and check the +# Dovecot login line: the "rip=" field should show the real client address rather +# than the proxy's. If it shows the proxy's address, login_trusted_networks does +# not include the proxy. diff --git a/resources/migrate_v015_to_plan.py b/resources/migrate_v015_to_plan.py new file mode 100644 index 0000000..efd6f56 --- /dev/null +++ b/resources/migrate_v015_to_plan.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Build a Stalwart CLI migration plan from a Stalwart v0.15 server. + +This reads principal (account) definitions from a running Stalwart v0.15 +server through its management API and emits an NDJSON plan that the Stalwart +CLI can apply against a new (latest) Stalwart server to recreate those +accounts: + + stalwart-cli apply --file migration-plan.ndjson + +Each line of the plan is a create operation in the form consumed by +`stalwart-cli apply`: + + {"@type":"create","object":"Domain","value":{"":{...}}} + {"@type":"create","object":"Account","value":{"":{"@type":"User",...}}} + +Accounts reference their domain through a plan-local "#" placeholder that +the CLI resolves while applying. When a domain already exists on the target +server, pass --existing-domain NAME=ID so accounts reference the real id +instead of trying to recreate the domain. + +Password hashes are migrated verbatim, so users keep their existing passwords. +App passwords and one-time-password (TOTP) secrets are not migrated. + +If a username is given, only that account is exported; otherwise every +individual (optionally every group with --include-groups) is exported. +""" + +import argparse +import base64 +import json +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request + +PAGE_SIZE = 200 +REQUEST_TIMEOUT = 60 + + +class ApiError(RuntimeError): + pass + + +class StalwartV015Client: + def __init__(self, url, token=None, username=None, password=None, insecure=False): + self.base = url.rstrip("/") + if token: + self.auth = "Bearer " + token + elif username is not None: + raw = f"{username}:{password or ''}".encode() + self.auth = "Basic " + base64.b64encode(raw).decode() + else: + raise ValueError("either a token or a username must be provided") + self.ctx = None + if insecure: + self.ctx = ssl.create_default_context() + self.ctx.check_hostname = False + self.ctx.verify_mode = ssl.CERT_NONE + + def get(self, path, params=None): + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, method="GET") + req.add_header("Authorization", self.auth) + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=self.ctx) as resp: + payload = json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + raise ApiError(f"{exc.code} {exc.reason} for {path}: {body}") from exc + except urllib.error.URLError as exc: + raise ApiError(f"cannot reach {url}: {exc.reason}") from exc + if isinstance(payload, dict) and "data" in payload: + return payload["data"] + return payload + + def list_principal_names(self, types): + names = [] + for kind in types: + page = 1 + while True: + data = self.get( + "/api/principal", + {"page": page, "limit": PAGE_SIZE, "types": kind}, + ) + items = data.get("items") or [] + for item in items: + name = principal_name(item) + if name: + names.append(name) + total = data.get("total") or 0 + if page * PAGE_SIZE >= total or not items: + break + page += 1 + return names + + def fetch_principal(self, name): + return self.get("/api/principal/" + urllib.parse.quote(name, safe="")) + + +def principal_name(record): + name = record.get("name") + if isinstance(name, dict): + return name.get("string") + return name + + +def scalar_list(value): + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, dict): + if "stringList" in value: + return value["stringList"] + if "string" in value: + return [value["string"]] + return list(value.values()) + return [value] + + +def scalar(value): + if isinstance(value, dict): + for key in ("string", "integer"): + if key in value: + return value[key] + return None + return value + + +def domain_ref(domain): + safe = "".join(c if c.isalnum() else "_" for c in domain) + return "domain_" + safe + + +def split_address(record): + name = principal_name(record) or "" + emails = [e for e in scalar_list(record.get("emails")) if e] + primary = emails[0] if emails else name + if "@" in primary: + local, _, domain = primary.partition("@") + elif "@" in name: + local, _, domain = name.partition("@") + primary = name + else: + local, domain = name, None + return local, domain, primary, emails + + +def build_credentials(record): + creds = {} + index = 0 + for secret in scalar_list(record.get("secrets")): + if not isinstance(secret, str) or not secret: + continue + if secret.startswith("$app$"): + continue + if secret.startswith("otpauth://"): + continue + creds[str(index)] = {"@type": "Password", "secret": secret} + index += 1 + return creds + + +def build_aliases(primary, emails, domain_refs, existing): + aliases = {} + index = 0 + for email in emails: + if email == primary or "@" not in email: + continue + local, _, domain = email.partition("@") + ref = existing.get(domain) or ("#" + domain_ref(domain)) + aliases[str(index)] = {"name": local, "domainId": ref} + domain_refs.add(domain) + index += 1 + return aliases + + +def build_account(record, domain_value, existing): + kind = scalar(record.get("type")) + local, domain, primary, emails = split_address(record) + if not domain: + raise ApiError( + f"principal {principal_name(record)!r} has no email domain; cannot map it" + ) + seen_domains = {domain} + domain_id = existing.get(domain) or ("#" + domain_ref(domain)) + + account = { + "@type": "Group" if kind == "group" else "User", + "name": local, + "domainId": domain_id, + } + description = scalar(record.get("description")) + if description: + account["description"] = description + creds = build_credentials(record) + if creds and kind != "group": + account["credentials"] = creds + if kind != "group": + account["roles"] = {"@type": "User"} + account["encryptionAtRest"] = {"@type": "Disabled"} + aliases = build_aliases(primary, emails, seen_domains, existing) + if aliases: + account["aliases"] = aliases + + for dom in seen_domains: + if dom not in existing: + domain_value.setdefault(domain_ref(dom), {"name": dom}) + return account + + +def main(): + parser = argparse.ArgumentParser( + description="Build a Stalwart CLI migration plan from a Stalwart v0.15 server.", + ) + parser.add_argument("username", nargs="?", help="export only this account (default: all)") + parser.add_argument("--url", required=True, help="base URL of the v0.15 server") + parser.add_argument("--token", help="admin bearer token") + parser.add_argument("--user", help="admin username (HTTP basic auth)") + parser.add_argument("--password", help="admin password (HTTP basic auth)") + parser.add_argument("--insecure", action="store_true", help="skip TLS verification") + parser.add_argument("--include-groups", action="store_true", help="also export groups") + parser.add_argument( + "--existing-domain", + action="append", + default=[], + metavar="NAME=ID", + help="reference an existing target domain by id instead of creating it", + ) + parser.add_argument( + "--output", + help="write the plan to this file (default: stdout)", + ) + args = parser.parse_args() + + if not args.token and args.user is None: + parser.error("provide --token or --user/--password") + + existing = {} + for entry in args.existing_domain: + if "=" not in entry: + parser.error(f"--existing-domain expects NAME=ID, got {entry!r}") + name, _, value = entry.partition("=") + existing[name.strip()] = value.strip() + + client = StalwartV015Client( + args.url, + token=args.token, + username=args.user, + password=args.password, + insecure=args.insecure, + ) + + if args.username: + names = [args.username] + else: + types = ["individual"] + if args.include_groups: + types.append("group") + names = client.list_principal_names(types) + + if not names: + sys.stderr.write("no accounts to export\n") + return 1 + + domain_value = {} + account_value = {} + for name in names: + record = client.fetch_principal(name) + account = build_account(record, domain_value, existing) + account_value["acct_" + str(scalar(record.get("id")) or name)] = account + + ops = [] + if domain_value: + ops.append({"@type": "create", "object": "Domain", "value": domain_value}) + ops.append({"@type": "create", "object": "Account", "value": account_value}) + + out = open(args.output, "w") if args.output else sys.stdout + try: + for op in ops: + out.write(json.dumps(op) + "\n") + finally: + if args.output: + out.close() + + sys.stderr.write( + f"exported {len(account_value)} account(s), {len(domain_value)} domain(s)\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())