From 27.1 the store layout is inbuxa's own, and Stalwart is a migration source. The one Stalwart format inbuxa keeps reading is 0.16.27's, by export and import. docs/spec/import-0.16.md says what carries over, what changes on first start, and what the operator has to change. tests/resources/import-0.16.27 is a real 0.16.27 export (84K): accounts, a group, a custom role, a tenant, mail with threads and an attachment, sharing, calendars, contacts, Sieve and a vacation response, plus a snapshot of what 0.16.27 served. tools/import-check/check.sh imports it into an image, compares what that serves against the snapshot, less the known differences (inbuxa's own role and permissions), then checks delivery through the imported filter, search after the reindex, and re-saving a pre-rename filter. It passes on 27.1-rc.4. make-fixture.sh rebuilds the fixture from the pinned 0.16.27 image, with no internet, so no upstream download ends up in it. SPEC.md and migration.md no longer promise an unchanged store format.
107 lines
6.1 KiB
Python
Executable File
107 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
"""Print a server's fixture data as id-free JSON, so a 0.16.27 source and an
|
|
inbuxa target can be compared field by field."""
|
|
import json, sys, urllib.request, os
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from jmap import Jmap
|
|
|
|
BASE = sys.argv[1]
|
|
A = json.load(open(os.path.join(os.environ["FIXTURE"], "admin.json")))
|
|
PW = {"alice": "alice-fixture-pass-1", "bob": "bob-fixture-pass-2", "carol": "carol-fixture-pass-3"}
|
|
DOMAIN = {"carol": "acme.example.com"}
|
|
REGISTRY_CAPS = ["urn:stalwart:jmap", "urn:inbuxa:jmap:registry"]
|
|
|
|
|
|
def admin_client():
|
|
# The registry capability changed name; ask the session which one it has.
|
|
probe = Jmap(BASE, A["username"], A["secret"])
|
|
caps = [c for c in REGISTRY_CAPS if c in probe.session["capabilities"]]
|
|
return Jmap(BASE, A["username"], A["secret"], caps)
|
|
|
|
|
|
def admin_view():
|
|
j = admin_client()
|
|
r = j.call(("x:Domain/get", {"ids": None}), ("x:Account/get", {"ids": None}),
|
|
("x:Role/get", {"ids": None}), ("x:DkimSignature/get", {"ids": None}))
|
|
domains = {d["id"]: d["name"] for d in r[0]["list"]}
|
|
names = {a["id"]: a["name"] for a in r[1]["list"]}
|
|
roles = {x["id"]: x.get("description") for x in r[2]["list"]}
|
|
accounts = {}
|
|
for a in r[1]["list"]:
|
|
role = a.get("roles") or {}
|
|
accounts[a["name"]] = {
|
|
"type": a["@type"], "description": a.get("description"),
|
|
"domain": domains.get(a.get("domainId")), "tenant": a.get("memberTenantId") is not None,
|
|
"aliases": sorted(f'{x["name"]}@{domains.get(x["domainId"])}' for x in (a.get("aliases") or {}).values()),
|
|
"groups": sorted(names.get(g, g) for g in (a.get("memberGroupIds") or {})),
|
|
"quotas": a.get("quotas") or {},
|
|
"roles": [role.get("@type")] + sorted(roles.get(i, i) for i in (role.get("roleIds") or {})),
|
|
}
|
|
role_perms = {x["description"]: {"enabled": sorted(k for k, v in (x.get("enabledPermissions") or {}).items() if v),
|
|
"disabled": sorted(k for k, v in (x.get("disabledPermissions") or {}).items() if v),
|
|
"inherits": sorted(roles.get(i, i) for i in (x.get("roleIds") or {}))}
|
|
for x in r[2]["list"]}
|
|
dkim = sorted(f'{domains.get(d.get("domainId"), "?")} {d["@type"]}' for d in r[3]["list"])
|
|
r = j.call(("x:Tenant/get", {"ids": None}), ("x:Application/get", {"ids": None}))
|
|
tenants = {t["id"]: t["name"] for t in r[0]["list"]}
|
|
apps = sorted(f'{x.get("description")} enabled={x.get("enabled")} {x.get("resourceUrl")}' for x in r[1]["list"])
|
|
return {"domains": sorted(domains.values()), "accounts": accounts, "roles": role_perms, "dkim": dkim,
|
|
"tenants": sorted(t["name"] for t in r[0]["list"]), "applications": apps}
|
|
|
|
|
|
def user_view(name):
|
|
j = Jmap(BASE, f"{name}@{DOMAIN.get(name, 'example.org')}", PW[name])
|
|
acct = j.account_id()
|
|
r = j.call(("Mailbox/get", {"accountId": acct, "ids": None}))
|
|
boxes = {m["id"]: m for m in r[0]["list"]}
|
|
|
|
def path(mid):
|
|
m = boxes[mid]
|
|
return (path(m["parentId"]) + "/" if m.get("parentId") else "") + m["name"]
|
|
|
|
mailboxes = {path(m["id"]): {"role": m.get("role"), "total": m["totalEmails"], "unread": m["unreadEmails"]}
|
|
for m in boxes.values()}
|
|
r = j.call(("Email/query", {"accountId": acct}),
|
|
("Email/get", {"accountId": acct, "#ids": {"resultOf": "0", "name": "Email/query", "path": "/ids"},
|
|
"properties": ["subject", "mailboxIds", "keywords", "threadId", "hasAttachment",
|
|
"size", "receivedAt", "messageId"]}))
|
|
threads = {}
|
|
for e in r[1]["list"]:
|
|
threads.setdefault(e["threadId"], []).append(e["subject"])
|
|
emails = sorted(({"subject": e["subject"], "mailboxes": sorted(path(b) for b in e["mailboxIds"]),
|
|
"keywords": sorted(k for k in e["keywords"] if not k.startswith("$has")),
|
|
"thread": sorted(threads[e["threadId"]]), "hasAttachment": e["hasAttachment"],
|
|
"size": e["size"], "receivedAt": e["receivedAt"], "messageId": e["messageId"]}
|
|
for e in r[1]["list"]), key=lambda e: e["subject"])
|
|
r = j.call(("CalendarEvent/get", {"accountId": acct, "ids": None}),
|
|
("ContactCard/get", {"accountId": acct, "ids": None}),
|
|
("SieveScript/get", {"accountId": acct, "ids": None}),
|
|
("VacationResponse/get", {"accountId": acct, "ids": ["singleton"]}))
|
|
events = sorted([{"title": e.get("title"), "start": e.get("start"), "timeZone": e.get("timeZone"),
|
|
"duration": e.get("duration"), "rrule": (e.get("recurrenceRule") or {}).get("frequency")}
|
|
for e in r[0]["list"]], key=lambda e: e["title"] or "")
|
|
cards = sorted([{"name": (c.get("name") or {}).get("full"),
|
|
"emails": sorted(x["address"] for x in (c.get("emails") or {}).values())}
|
|
for c in r[1]["list"]], key=lambda c: c["name"] or "")
|
|
sieve = []
|
|
for s in r[2]["list"]:
|
|
url = j.session["downloadUrl"].replace("{accountId}", acct).replace("{blobId}", s["blobId"]) \
|
|
.replace("{name}", "s").replace("{type}", "application/sieve")
|
|
url = j.local(url)
|
|
req = urllib.request.Request(url, headers=j.auth)
|
|
sieve.append({"name": s["name"], "isActive": s["isActive"],
|
|
"content": urllib.request.urlopen(req).read().decode()})
|
|
v = r[3]["list"][0]
|
|
vacation = {k: v.get(k) for k in ("isEnabled", "subject", "textBody")}
|
|
shared = sorted(a["name"] for k, a in j.session["accounts"].items() if k != acct)
|
|
return {"mailboxes": mailboxes, "emails": emails, "events": events, "contacts": cards,
|
|
"sieve": sorted(sieve, key=lambda s: s["name"]), "vacation": vacation, "sharedAccounts": shared}
|
|
|
|
|
|
print(json.dumps({"admin": admin_view(), "alice": user_view("alice"), "bob": user_view("bob"),
|
|
"carol": user_view("carol")},
|
|
indent=2, sort_keys=True))
|