#!/usr/bin/env python3 import argparse import json import os import sqlite3 import sys from datetime import datetime STANDARD_FLAGS = { "$seen": "S", "$answered": "R", "$flagged": "F", "$draft": "D", "$deleted": "T", } def fail(message): print(f"error: {message}", file=sys.stderr) sys.exit(1) def sanitize(component): out = [] for ch in component: if ch in ("/", "\\", "\x00") or ord(ch) < 0x20: out.append("_") else: out.append(ch) cleaned = "".join(out).strip().rstrip(".") return cleaned or "_unnamed" def unique_dir(parent, label): base = sanitize(label) candidate = os.path.join(parent, base) suffix = 1 while os.path.exists(candidate): candidate = os.path.join(parent, f"{base}-{suffix}") suffix += 1 os.makedirs(candidate) return candidate def blob_bytes(conn, blob_id): if blob_id is None: return None row = conn.execute("SELECT data FROM blobs WHERE id = ?", (blob_id,)).fetchone() return row[0] if row is not None else None def epoch_of(received_at): text = (received_at or "").strip() if not text: return 0 if text.endswith("Z"): text = text[:-1] + "+00:00" try: return int(datetime.fromisoformat(text).timestamp()) except ValueError: return 0 def keywords_to_info(keywords_json): try: keywords = json.loads(keywords_json) except (ValueError, TypeError): keywords = [] letters = set() for keyword in keywords: mapped = STANDARD_FLAGS.get(str(keyword).lower()) if mapped: letters.add(mapped) return "".join(sorted(letters)) def ensure_maildir(path): for sub in ("cur", "new", "tmp"): os.makedirs(os.path.join(path, sub), exist_ok=True) def maildir_paths(conn, root): rows = conn.execute( "SELECT id, name, parent_id, role FROM mailboxes" ).fetchall() info = { row[0]: {"name": row[1], "parent": row[2], "role": row[3]} for row in rows } def components(mailbox_id): chain = [] cursor = mailbox_id seen = set() while cursor is not None and cursor not in seen: seen.add(cursor) node = info.get(cursor) if node is None: break chain.append((node["name"], node["role"])) cursor = node["parent"] chain.reverse() if chain and chain[0][1] == "inbox": chain = chain[1:] return [sanitize(name) for name, _ in chain] paths = {} os.makedirs(root, exist_ok=True) ensure_maildir(root) for mailbox_id in info: comps = components(mailbox_id) path = root if not comps else os.path.join(root, "." + ".".join(comps)) ensure_maildir(path) paths[mailbox_id] = path return paths def export_mail(conn, root): paths = maildir_paths(conn, root) rows = conn.execute( "SELECT e.id, e.received_at, e.mailbox_ids, e.keywords, b.data " "FROM emails e JOIN blobs b ON b.id = e.blob_id" ).fetchall() sequence = 0 written = 0 for email_id, received_at, mailbox_ids_json, keywords_json, data in rows: try: mailbox_ids = json.loads(mailbox_ids_json) except (ValueError, TypeError): mailbox_ids = [] info = keywords_to_info(keywords_json) stamp = epoch_of(received_at) for mailbox_id in mailbox_ids: directory = paths.get(mailbox_id) if directory is None: continue sequence += 1 name = f"{stamp}.M{email_id}P{sequence}.vandelay:2,{info}" with open(os.path.join(directory, "cur", name), "wb") as handle: handle.write(data) written += 1 return written def resolve_blob_sentinels(conn, value, attachments_dir): if isinstance(value, dict): if "@blob" in value and isinstance(value["@blob"], int): blob_id = value["@blob"] payload = blob_bytes(conn, blob_id) if payload is not None: os.makedirs(attachments_dir, exist_ok=True) filename = f"blob-{blob_id}.bin" with open(os.path.join(attachments_dir, filename), "wb") as handle: handle.write(payload) value["@blob"] = os.path.join( os.path.basename(attachments_dir), filename ) for nested in value.values(): resolve_blob_sentinels(conn, nested, attachments_dir) elif isinstance(value, list): for nested in value: resolve_blob_sentinels(conn, nested, attachments_dir) def write_json_item(conn, directory, label, payload): attachments_dir = os.path.join(directory, "_attachments") resolve_blob_sentinels(conn, payload, attachments_dir) base = sanitize(label) candidate = os.path.join(directory, f"{base}.json") suffix = 1 while os.path.exists(candidate): candidate = os.path.join(directory, f"{base}-{suffix}.json") suffix += 1 with open(candidate, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2, ensure_ascii=False) def export_collections(conn, root, container_table, container_select, item_query, container_label, item_membership_field, augment): os.makedirs(root, exist_ok=True) containers = {} for row in conn.execute(container_select).fetchall(): containers[row[0]] = unique_dir(root, container_label(row)) orphans = None written = 0 for row in conn.execute(item_query).fetchall(): payload, member_ids, label = augment(row) targets = [containers[c] for c in member_ids if c in containers] if not targets: if orphans is None: orphans = unique_dir(root, "_orphans") targets = [orphans] for directory in targets: write_json_item(conn, directory, label, json.loads(json.dumps(payload))) written += 1 return written def export_calendars(conn, root): def container_label(row): return row[1] or f"calendar-{row[0]}" def augment(row): event_id, calendar_ids_json, is_draft, use_default_alerts, data_json, data_type = row payload = json.loads(data_json) if "@type" not in payload and data_type: payload["@type"] = data_type payload["isDraft"] = bool(is_draft) payload["useDefaultAlerts"] = bool(use_default_alerts) try: calendar_ids = json.loads(calendar_ids_json) except (ValueError, TypeError): calendar_ids = [] payload["calendarIds"] = calendar_ids label = payload.get("uid") or f"event-{event_id}" return payload, calendar_ids, label return export_collections( conn, root, "calendars", "SELECT id, name FROM calendars", "SELECT id, calendar_ids, is_draft, use_default_alerts, data, data_type " "FROM calendar_events", container_label, "calendar_ids", augment, ) def export_contacts(conn, root): def container_label(row): return row[1] or f"addressbook-{row[0]}" def augment(row): card_id, uid, address_book_ids_json, data_json = row payload = json.loads(data_json) payload["uid"] = uid try: address_book_ids = json.loads(address_book_ids_json) except (ValueError, TypeError): address_book_ids = [] label = uid or f"card-{card_id}" return payload, address_book_ids, label return export_collections( conn, root, "address_books", "SELECT id, name FROM address_books", "SELECT id, uid, address_book_ids, data FROM contact_cards", container_label, "address_book_ids", augment, ) def export_sieve(conn, root): rows = conn.execute( "SELECT s.id, s.name, s.is_active, b.data " "FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id" ).fetchall() if not rows: return 0 os.makedirs(root, exist_ok=True) written = 0 for script_id, name, is_active, data in rows: label = sanitize(name or f"script-{script_id}") with open(os.path.join(root, f"{label}.sieve"), "wb") as handle: handle.write(data) if is_active: with open(os.path.join(root, "active.sieve"), "wb") as handle: handle.write(data) written += 1 return written def export_files(conn, root): rows = conn.execute( "SELECT id, parent_id, node_type, blob_id, target, name FROM file_nodes" ).fetchall() if not rows: return 0 children = {} for row in rows: children.setdefault(row[1], []).append(row) os.makedirs(root, exist_ok=True) written = 0 def walk(parent_id, directory): nonlocal written for node_id, _parent, node_type, blob_id, target_json, name in children.get( parent_id, [] ): path = os.path.join(directory, sanitize(name)) if node_type == "directory": os.makedirs(path, exist_ok=True) walk(node_id, path) elif node_type == "symlink": try: target = json.loads(target_json) if target_json else [] except (ValueError, TypeError): target = [] link_target = "/".join(str(part) for part in target) try: os.symlink(link_target, path) written += 1 except (OSError, NotImplementedError): pass else: payload = blob_bytes(conn, blob_id) with open(path, "wb") as handle: handle.write(payload if payload is not None else b"") written += 1 walk(None, root) return written def main(): parser = argparse.ArgumentParser( description="Export a Vandelay SQLite archive to a directory tree." ) parser.add_argument("archive") parser.add_argument("target") args = parser.parse_args() if not os.path.isfile(args.archive): fail(f"archive not found: {args.archive}") if os.path.exists(args.target): fail(f"target already exists: {args.target}") os.makedirs(args.target) uri = f"file:{os.path.abspath(args.archive)}?mode=ro&immutable=1" conn = sqlite3.connect(uri, uri=True) try: emails = export_mail(conn, os.path.join(args.target, "mail")) events = export_calendars(conn, os.path.join(args.target, "calendars")) cards = export_contacts(conn, os.path.join(args.target, "contacts")) scripts = export_sieve(conn, os.path.join(args.target, "sieve")) files = export_files(conn, os.path.join(args.target, "files")) finally: conn.close() print(f"emails: {emails}") print(f"events: {events}") print(f"contacts: {cards}") print(f"sieve: {scripts}") print(f"files: {files}") if __name__ == "__main__": main()