From 987ed55d068d82368be429de841a76e575582dc8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 00:02:01 -0700 Subject: [PATCH] record-before: the step 4 recording, from the server about to be replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cutover-run.md step 4 says to write down what has to be true afterwards while the old server can still be asked, and step 10 checks against it. Done by hand it gets skipped, and skipping it turns "each mailbox holds what was recorded" into "each mailbox holds something", which is a different check and will not catch a partial copy. It reuses record-compat.py's client rather than growing a second one, so the guard that makes it safe to point at a live server — call() refuses any method that is not a /get or a /query — covers this too. Verified that it bites: x:Account/set is refused before anything is sent. From the administrator alone it records every account with its address, aliases, tenant and usedDiskQuota, which is the number that moves if mail goes missing, plus the domains and tenants. Exact per-mailbox counts need the mailbox's own credentials, since an administrator has reach over an account but not always into it, so --as takes one and repeats. For a handful of mailboxes that is worth it: it makes step 10 an equality rather than an estimate. Aliases are resolved to the domain's name rather than its id, because an id is not what anyone checks against at 2am. --- tools/fork/record-before.py | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100755 tools/fork/record-before.py diff --git a/tools/fork/record-before.py b/tools/fork/record-before.py new file mode 100755 index 0000000..204645c --- /dev/null +++ b/tools/fork/record-before.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: AGPL-3.0-only +""" +Record what must still be true after the cutover, from the server that is +about to be replaced. + + tools/fork/record-before.py --server https://mail.example.org \ + --admin 'admin@example.org:PASSWORD' --out ./before.json + +Step 4 of `docs/spec/cutover-run.md`: write down what has to be true +afterwards, while the old server can still be asked. Step 10 then checks +against it. Skipping this turns "each mailbox holds what was recorded" into +"each mailbox holds something", which is not the same check and will not +catch a partial copy. + +**This script only reads.** It reuses `record-compat.py`'s client, whose +`call` refuses any method that is not a `/get` or a `/query`, so it is safe +against the live server that the hand-off brief otherwise bars touching. + +What it records from the administrator alone, needing nobody's password: + +- every account: its name, address, aliases, description, roles, tenant, + quota and **usedDiskQuota**, which is the number that moves if mail goes + missing; +- every domain, and every tenant. + +Exact per-mailbox message counts need the mailbox's own credentials, since +an administrator has reach over accounts but not always into them. Pass +`--as name:password` once per mailbox to record those too — worth it for a +handful of mailboxes, and it makes step 10 an equality rather than an +estimate. + +Exit status: 0 recorded, 1 a server refusal or nothing to record, 2 usage. +""" + +import argparse +import importlib.util +import json +import os +import pathlib +import sys +import urllib.request + +# record-compat.py is not an importable name, and duplicating its client +# would duplicate the read-only guard that makes this safe to point at the +# live server. Load it by path instead. +_spec = importlib.util.spec_from_file_location( + 'record_compat', pathlib.Path(__file__).with_name('record-compat.py')) +_compat = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_compat) + +Client, Refused, fail = _compat.Client, _compat.Refused, _compat.fail + +ACCOUNT_PROPERTIES = [ + 'id', 'name', 'description', 'emailAddress', 'aliases', 'roles', + 'domainId', 'memberTenantId', 'quotas', 'usedDiskQuota', +] + + +def record_accounts(admin, domains): + """Every account the administrator can see, with what identifies it.""" + ids = admin.query_ids('Account') + if not ids: + return {} + accounts = {} + for account in admin.call('x:Account/get', + {'ids': ids, 'properties': ACCOUNT_PROPERTIES}).get('list', []): + id = account.get('id') + aliases = account.get('aliases') or {} + accounts[id] = { + 'name': account.get('name'), + 'address': account.get('emailAddress'), + 'description': account.get('description'), + 'type': (account.get('roles') or {}).get('@type'), + 'domainId': account.get('domainId'), + 'memberTenantId': account.get('memberTenantId'), + 'usedDiskQuota': account.get('usedDiskQuota'), + 'quotas': account.get('quotas'), + # Sorted and flattened: an alias moving position is not a change. + # Resolved to the domain's name: an id is not what anyone + # checks against at 2am. + 'aliases': sorted( + '{}@{}'.format( + a.get('name'), + (domains.get(a.get('domainId')) or {}).get('name') or a.get('domainId'), + ) + for a in (aliases.values() if isinstance(aliases, dict) else aliases) + if a.get('enabled', True) + ), + } + return accounts + + +def record_domains(admin): + ids = admin.query_ids('Domain') + if not ids: + return {} + return { + d.get('id'): {'name': d.get('name'), 'memberTenantId': d.get('memberTenantId')} + for d in admin.get('Domain', ids=ids) + } + + +def record_tenants(admin): + try: + return {t.get('id'): {'name': t.get('name')} for t in admin.get('Tenant')} + except Refused: + # A server with no tenancy in use refuses rather than returning none. + return {} + + +def record_mailbox(server, credentials, insecure): + """Exact message count for one mailbox, signed in as that mailbox.""" + client = Client(server, credentials, insecure) + if not client.authenticates(): + fail(f'{client.name} did not authenticate; --as wants name:password', code=1) + # Its own account id, from the session, rather than guessing it. + request = urllib.request.Request( + f'{client.server}/jmap/session', + headers={'Authorization': f'Basic {client.auth}'}) + with urllib.request.urlopen(request, context=client.ctx, timeout=30) as response: + session = json.load(response) + primary = session.get('primaryAccounts') or {} + session_account = (primary.get('urn:ietf:params:jmap:mail') + or next(iter(session.get('accounts') or {}), None)) + if not session_account: + fail(f'{client.name}: no account in its session', code=1) + + # Through call(), not _post(), so the read-only guard still applies. + result = client.call('Email/query', {'accountId': session_account}) + return {'accountId': session_account, 'messages': len(result.get('ids', []))} + + +def main(): + parser = argparse.ArgumentParser( + description='Record the pre-cutover state from the running server.') + parser.add_argument('--server', required=True, + help='https://mail.example.org') + parser.add_argument('--admin', required=True, metavar='NAME:PASSWORD', + help='an administrator with the run of the server') + parser.add_argument('--as', dest='mailboxes', action='append', default=[], + metavar='NAME:PASSWORD', + help='a mailbox to count exactly; repeatable') + parser.add_argument('--out', default='./before.json') + parser.add_argument('--insecure', action='store_true', + help='accept a self-signed certificate') + args = parser.parse_args() + + admin = Client(args.server, args.admin, args.insecure) + if not admin.authenticates(): + fail(f'{admin.name} did not authenticate against {args.server}', code=1) + + try: + domains = record_domains(admin) + record = { + 'server': args.server.rstrip('/'), + 'domains': domains, + 'accounts': record_accounts(admin, domains), + 'tenants': record_tenants(admin), + 'mailboxes': {}, + } + for credentials in args.mailboxes: + name = credentials.split(':', 1)[0] + record['mailboxes'][name] = record_mailbox(args.server, credentials, args.insecure) + except Refused as refusal: + fail(str(refusal), code=1) + + if not record['accounts']: + fail('no accounts read; nothing to record', code=1) + + path = os.path.abspath(args.out) + with open(path, 'w') as handle: + json.dump(record, handle, indent=1, sort_keys=True) + os.chmod(path, 0o600) + + print(f"recorded {len(record['accounts'])} account(s), " + f"{len(record['domains'])} domain(s), " + f"{len(record['tenants'])} tenant(s), " + f"{len(record['mailboxes'])} counted mailbox(es)") + for id, account in sorted(record['accounts'].items(), key=lambda kv: kv[1]['name'] or ''): + quota = account['usedDiskQuota'] + aliases = (' aliases=' + ','.join(account['aliases'])) if account['aliases'] else '' + print(f" {account['name']:<16} {account['address'] or '':<28} " + f"used={quota if quota is not None else '?'}{aliases}") + for name, box in sorted(record['mailboxes'].items()): + print(f" {name:<16} messages={box['messages']}") + print(f'wrote {path} (0600)') + return 0 + + +if __name__ == '__main__': + sys.exit(main())