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())