# Magnum Opus v0.1 — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Prove the end-to-end pipeline: backend orchestrates mbsync to sync IMAP → indexes Maildir with notmuch → caches in SQLite → exposes REST+SSE API → macOS SwiftUI client displays emails in a three-column layout with threaded detail view. **Architecture:** A Hono/Bun backend on Uberspace syncs a single IMAP account via mbsync, indexes with notmuch, caches metadata in SQLite, and exposes a REST+SSE API. A macOS SwiftUI client connects to this API to display a sidebar, email list, and threaded detail view. **Tech Stack:** Bun, Hono, SQLite (bun:sqlite), mbsync, notmuch, SwiftUI, async/await URLSession **Design Document:** `docs/plans/2026-03-10-magnum-opus-design.md` --- ## Phase 1: Backend Foundation ### Task 1: Project Scaffolding **Files:** - Create: `backend/package.json` - Create: `backend/tsconfig.json` - Create: `backend/src/index.ts` - Create: `backend/.env.example` - Create: `backend/.gitignore` **Step 1: Initialize project** ```bash cd /Users/felixfoertsch/Developer/MagnumOpus mkdir -p backend/src cd backend bun init -y bun add hono bun add -d @types/bun ``` **Step 2: Create entry point** Create `backend/src/index.ts`: ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; const app = new Hono(); app.use("*", cors()); app.get("/health", (c) => c.json({ status: "ok" })); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch, }; ``` **Step 3: Create `.env.example`** ``` PORT=3000 MAIL_DIR=~/Mail NOTMUCH_DATABASE=~/Mail ``` **Step 4: Create `.gitignore`** ``` node_modules/ *.db .env ``` **Step 5: Add dev script to `package.json`** ```json { "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", "test": "bun test" } } ``` **Step 6: Verify it runs** ```bash cd backend && bun run dev # In another terminal: curl http://localhost:3000/health # Expected: {"status":"ok"} ``` **Step 7: Commit** ```bash git add backend/ git commit -m "scaffold backend with hono/bun" ``` --- ### Task 2: mbsync Configuration Generator Generate mbsync config from account settings so users don't hand-edit `.mbsyncrc`. **Files:** - Create: `backend/src/services/sync.ts` - Create: `backend/src/services/sync.test.ts` **Step 1: Write the failing test** Create `backend/src/services/sync.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { generateMbsyncConfig } from "./sync"; describe("generateMbsyncConfig", () => { test("generates valid mbsyncrc for a single IMAP account", () => { const config = generateMbsyncConfig({ id: "personal", host: "mail.example.com", user: "user@example.com", passCmd: 'cat ~/.mail-pass', sslType: "IMAPS", mailDir: "/home/user/Mail/personal", }); expect(config).toContain("IMAPAccount personal"); expect(config).toContain("Host mail.example.com"); expect(config).toContain("User user@example.com"); expect(config).toContain('PassCmd "cat ~/.mail-pass"'); expect(config).toContain("SSLType IMAPS"); expect(config).toContain("Path /home/user/Mail/personal/"); expect(config).toContain("Inbox /home/user/Mail/personal/Inbox"); expect(config).toContain("Channel personal"); expect(config).toContain("Far :personal-remote:"); expect(config).toContain("Near :personal-local:"); expect(config).toContain("Patterns *"); expect(config).toContain("Create Both"); expect(config).toContain("SyncState *"); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/services/sync.test.ts # Expected: FAIL — generateMbsyncConfig not defined ``` **Step 3: Write minimal implementation** Create `backend/src/services/sync.ts`: ```typescript export interface ImapAccount { id: string; host: string; user: string; passCmd: string; sslType: "IMAPS" | "STARTTLS"; mailDir: string; } export function generateMbsyncConfig(account: ImapAccount): string { const { id, host, user, passCmd, sslType, mailDir } = account; const trailingSlashDir = mailDir.endsWith("/") ? mailDir : `${mailDir}/`; return `IMAPAccount ${id} Host ${host} User ${user} PassCmd "${passCmd}" SSLType ${sslType} IMAPStore ${id}-remote Account ${id} MaildirStore ${id}-local Subfolders Verbatim Path ${trailingSlashDir} Inbox ${trailingSlashDir}Inbox Channel ${id} Far :${id}-remote: Near :${id}-local: Patterns * Create Both Expunge None SyncState * `; } ``` Note: `Expunge None` — we never delete on the remote. Append-only principle. **Step 4: Run test to verify it passes** ```bash cd backend && bun test src/services/sync.test.ts # Expected: PASS ``` **Step 5: Commit** ```bash git add backend/src/services/ git commit -m "add mbsync config generator" ``` --- ### Task 3: Sync Orchestrator Wrap mbsync and notmuch CLI calls. Uses `Bun.spawn` with explicit argument arrays (no shell interpolation) for safety. **Files:** - Create: `backend/src/services/orchestrator.ts` - Create: `backend/src/services/orchestrator.test.ts` **Step 1: Write the failing test** Create `backend/src/services/orchestrator.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { runCommand } from "./orchestrator"; describe("runCommand", () => { test("runs a command and returns stdout", async () => { const result = await runCommand("echo", ["hello"]); expect(result.stdout.trim()).toBe("hello"); expect(result.exitCode).toBe(0); }); test("returns non-zero exit code on failure", async () => { const result = await runCommand("false", []); expect(result.exitCode).not.toBe(0); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/services/orchestrator.test.ts # Expected: FAIL — runCommand not defined ``` **Step 3: Write minimal implementation** Create `backend/src/services/orchestrator.ts`: ```typescript export interface CommandResult { stdout: string; stderr: string; exitCode: number; } export async function runCommand(cmd: string, args: string[]): Promise { const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe", }); const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; return { stdout, stderr, exitCode }; } export async function syncMail(channelName: string): Promise { return runCommand("mbsync", [channelName]); } export async function indexMail(): Promise { return runCommand("notmuch", ["new"]); } export async function syncAndIndex(channelName: string): Promise { const syncResult = await syncMail(channelName); if (syncResult.exitCode !== 0) { return syncResult; } return indexMail(); } ``` **Step 4: Run test to verify it passes** ```bash cd backend && bun test src/services/orchestrator.test.ts # Expected: PASS (only testing runCommand with echo/false, not mbsync/notmuch) ``` **Step 5: Commit** ```bash git add backend/src/services/orchestrator.ts backend/src/services/orchestrator.test.ts git commit -m "add sync orchestrator wrapping mbsync, notmuch" ``` --- ### Task 4: notmuch Query Service Wrap notmuch CLI to query threads and messages, parse JSON output. **Files:** - Create: `backend/src/services/notmuch.ts` - Create: `backend/src/services/notmuch.test.ts` - Create: `backend/test/fixtures/notmuch-search-output.json` - Create: `backend/test/fixtures/notmuch-show-output.json` **Step 1: Create test fixtures** These fixtures represent real notmuch JSON output. The tests parse these to validate our parsing logic without needing a live notmuch database. Create `backend/test/fixtures/notmuch-search-output.json`: ```json [ { "thread": "0000000000000001", "timestamp": 1709884532, "date_relative": "2024-03-08", "matched": 1, "total": 3, "authors": "Alice, Bob", "subject": "Q1 Planning", "query": ["id:msg001@example.com", null], "tags": ["inbox", "unread"] }, { "thread": "0000000000000002", "timestamp": 1709798132, "date_relative": "2024-03-07", "matched": 1, "total": 1, "authors": "Charlie", "subject": "Invoice #4521", "query": ["id:msg002@example.com", null], "tags": ["inbox"] } ] ``` Create `backend/test/fixtures/notmuch-show-output.json`: ```json [ [ [ { "id": "msg001@example.com", "match": true, "excluded": false, "filename": ["/home/user/Mail/personal/Inbox/cur/1709884532.M1P99.host:2,S"], "timestamp": 1709884532, "date_relative": "2024-03-08", "tags": ["inbox", "unread"], "headers": { "Subject": "Q1 Planning", "From": "Alice ", "To": "user@example.com", "Date": "Fri, 08 Mar 2024 10:15:32 +0100", "Message-ID": "msg001@example.com", "In-Reply-To": "", "References": "" }, "body": [ { "id": 1, "content-type": "text/plain", "content": "Hey, let's plan Q1.\n" } ] }, [ [ { "id": "msg003@example.com", "match": true, "excluded": false, "filename": ["/home/user/Mail/personal/Inbox/cur/1709884600.M2P99.host:2,RS"], "timestamp": 1709884600, "date_relative": "2024-03-08", "tags": ["inbox"], "headers": { "Subject": "Re: Q1 Planning", "From": "Bob ", "To": "alice@example.com, user@example.com", "Date": "Fri, 08 Mar 2024 10:16:40 +0100", "Message-ID": "msg003@example.com", "In-Reply-To": "msg001@example.com", "References": "msg001@example.com" }, "body": [ { "id": 1, "content-type": "text/plain", "content": "Sounds good. Tuesday work?\n" } ] }, [] ] ] ] ] ] ``` **Step 2: Write the failing test** Create `backend/src/services/notmuch.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { parseSearchResults, parseThreadMessages } from "./notmuch"; const searchFixture = await Bun.file("test/fixtures/notmuch-search-output.json").json(); const showFixture = await Bun.file("test/fixtures/notmuch-show-output.json").json(); describe("parseSearchResults", () => { test("parses notmuch search JSON into thread summaries", () => { const threads = parseSearchResults(searchFixture); expect(threads).toHaveLength(2); expect(threads[0].threadId).toBe("0000000000000001"); expect(threads[0].subject).toBe("Q1 Planning"); expect(threads[0].authors).toBe("Alice, Bob"); expect(threads[0].totalMessages).toBe(3); expect(threads[0].tags).toContain("unread"); expect(threads[0].timestamp).toBe(1709884532); }); }); describe("parseThreadMessages", () => { test("flattens notmuch show JSON into ordered messages", () => { const messages = parseThreadMessages(showFixture); expect(messages).toHaveLength(2); expect(messages[0].messageId).toBe("msg001@example.com"); expect(messages[0].from).toBe("Alice "); expect(messages[0].subject).toBe("Q1 Planning"); expect(messages[0].body).toBe("Hey, let's plan Q1.\n"); expect(messages[1].messageId).toBe("msg003@example.com"); expect(messages[1].inReplyTo).toBe("msg001@example.com"); }); }); ``` **Step 3: Run test to verify it fails** ```bash cd backend && bun test src/services/notmuch.test.ts # Expected: FAIL — parseSearchResults, parseThreadMessages not defined ``` **Step 4: Write minimal implementation** Create `backend/src/services/notmuch.ts`: ```typescript import { runCommand } from "./orchestrator"; export interface ThreadSummary { threadId: string; subject: string; authors: string; totalMessages: number; tags: string[]; timestamp: number; } export interface Message { messageId: string; from: string; to: string; subject: string; date: string; inReplyTo: string; references: string; body: string; tags: string[]; timestamp: number; } export function parseSearchResults(raw: unknown[]): ThreadSummary[] { return (raw as Array>).map((entry) => ({ threadId: entry.thread as string, subject: entry.subject as string, authors: entry.authors as string, totalMessages: entry.total as number, tags: entry.tags as string[], timestamp: entry.timestamp as number, })); } export function parseThreadMessages(raw: unknown[]): Message[] { const messages: Message[] = []; flattenThread(raw, messages); return messages.sort((a, b) => a.timestamp - b.timestamp); } function flattenThread(node: unknown, messages: Message[]): void { if (!Array.isArray(node)) return; for (const item of node) { if (isMessageNode(item)) { const headers = item.headers as Record; const bodyParts = item.body as Array>; const plainPart = bodyParts.find((p) => p["content-type"] === "text/plain"); messages.push({ messageId: item.id as string, from: headers.From ?? "", to: headers.To ?? "", subject: headers.Subject ?? "", date: headers.Date ?? "", inReplyTo: headers["In-Reply-To"] ?? "", references: headers.References ?? "", body: (plainPart?.content as string) ?? "", tags: item.tags as string[], timestamp: item.timestamp as number, }); } else if (Array.isArray(item)) { flattenThread(item, messages); } } } function isMessageNode(item: unknown): item is Record { return typeof item === "object" && item !== null && "id" in item && "headers" in item; } export async function searchThreads(query: string): Promise { const result = await runCommand("notmuch", ["search", "--format=json", query]); if (result.exitCode !== 0) { throw new Error(`notmuch search failed: ${result.stderr}`); } return parseSearchResults(JSON.parse(result.stdout)); } export async function getThread(threadId: string): Promise { const result = await runCommand("notmuch", ["show", "--format=json", `thread:${threadId}`]); if (result.exitCode !== 0) { throw new Error(`notmuch show failed: ${result.stderr}`); } return parseThreadMessages(JSON.parse(result.stdout)); } ``` **Step 5: Run test to verify it passes** ```bash cd backend && bun test src/services/notmuch.test.ts # Expected: PASS ``` **Step 6: Commit** ```bash git add backend/src/services/notmuch.ts backend/src/services/notmuch.test.ts backend/test/ git commit -m "add notmuch query service with JSON parsing" ``` --- ### Task 5: SQLite Cache Schema Cache thread and message metadata for fast API queries. **Files:** - Create: `backend/src/db/index.ts` - Create: `backend/src/db/schema.ts` - Create: `backend/src/db/index.test.ts` **Step 1: Write the failing test** Create `backend/src/db/index.test.ts`: ```typescript import { describe, expect, test, beforeEach } from "bun:test"; import { createDatabase, insertThread, insertMessage, getThreads, getMessagesForThread } from "./index"; import type { Database } from "bun:sqlite"; let db: Database; beforeEach(() => { db = createDatabase(":memory:"); }); describe("threads", () => { test("insert and retrieve threads", () => { insertThread(db, { threadId: "t001", subject: "Q1 Planning", authors: "Alice, Bob", totalMessages: 3, tags: "inbox,unread", timestamp: 1709884532, accountId: "personal", }); const threads = getThreads(db, "personal"); expect(threads).toHaveLength(1); expect(threads[0].threadId).toBe("t001"); expect(threads[0].subject).toBe("Q1 Planning"); }); }); describe("messages", () => { test("insert and retrieve messages for a thread", () => { insertMessage(db, { messageId: "msg001@example.com", threadId: "t001", fromHeader: "Alice ", toHeader: "user@example.com", subject: "Q1 Planning", date: "2024-03-08T10:15:32+01:00", inReplyTo: "", body: "Hey, let's plan Q1.", tags: "inbox,unread", timestamp: 1709884532, accountId: "personal", }); const messages = getMessagesForThread(db, "t001"); expect(messages).toHaveLength(1); expect(messages[0].messageId).toBe("msg001@example.com"); expect(messages[0].body).toBe("Hey, let's plan Q1."); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/db/index.test.ts # Expected: FAIL — createDatabase not defined ``` **Step 3: Create schema** Create `backend/src/db/schema.ts`: ```typescript export const SCHEMA = ` CREATE TABLE IF NOT EXISTS threads ( thread_id TEXT NOT NULL, account_id TEXT NOT NULL, subject TEXT NOT NULL, authors TEXT NOT NULL, total_messages INTEGER NOT NULL, tags TEXT NOT NULL DEFAULT '', timestamp INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (thread_id, account_id) ); CREATE TABLE IF NOT EXISTS messages ( message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, account_id TEXT NOT NULL, from_header TEXT NOT NULL, to_header TEXT NOT NULL, subject TEXT NOT NULL, date TEXT NOT NULL, in_reply_to TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '', timestamp INTEGER NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_threads_account ON threads(account_id); CREATE INDEX IF NOT EXISTS idx_threads_timestamp ON threads(timestamp DESC); `; ``` **Step 4: Write implementation** Create `backend/src/db/index.ts`: ```typescript import { Database } from "bun:sqlite"; import { SCHEMA } from "./schema"; export function createDatabase(path: string): Database { const db = new Database(path, { create: true }); db.run("PRAGMA journal_mode = WAL"); db.exec(SCHEMA); return db; } export interface ThreadRow { threadId: string; subject: string; authors: string; totalMessages: number; tags: string; timestamp: number; accountId: string; } export interface MessageRow { messageId: string; threadId: string; fromHeader: string; toHeader: string; subject: string; date: string; inReplyTo: string; body: string; tags: string; timestamp: number; accountId: string; } export function insertThread(db: Database, thread: ThreadRow): void { db.prepare(` INSERT OR REPLACE INTO threads (thread_id, account_id, subject, authors, total_messages, tags, timestamp) VALUES ($threadId, $accountId, $subject, $authors, $totalMessages, $tags, $timestamp) `).run({ $threadId: thread.threadId, $accountId: thread.accountId, $subject: thread.subject, $authors: thread.authors, $totalMessages: thread.totalMessages, $tags: thread.tags, $timestamp: thread.timestamp, }); } export function insertMessage(db: Database, msg: MessageRow): void { db.prepare(` INSERT OR REPLACE INTO messages (message_id, thread_id, account_id, from_header, to_header, subject, date, in_reply_to, body, tags, timestamp) VALUES ($messageId, $threadId, $accountId, $fromHeader, $toHeader, $subject, $date, $inReplyTo, $body, $tags, $timestamp) `).run({ $messageId: msg.messageId, $threadId: msg.threadId, $accountId: msg.accountId, $fromHeader: msg.fromHeader, $toHeader: msg.toHeader, $subject: msg.subject, $date: msg.date, $inReplyTo: msg.inReplyTo, $body: msg.body, $tags: msg.tags, $timestamp: msg.timestamp, }); } export function getThreads(db: Database, accountId: string): ThreadRow[] { return db.prepare(` SELECT thread_id as threadId, subject, authors, total_messages as totalMessages, tags, timestamp, account_id as accountId FROM threads WHERE account_id = $accountId ORDER BY timestamp DESC `).all({ $accountId: accountId }) as ThreadRow[]; } export function getMessagesForThread(db: Database, threadId: string): MessageRow[] { return db.prepare(` SELECT message_id as messageId, thread_id as threadId, from_header as fromHeader, to_header as toHeader, subject, date, in_reply_to as inReplyTo, body, tags, timestamp, account_id as accountId FROM messages WHERE thread_id = $threadId ORDER BY timestamp ASC `).all({ $threadId: threadId }) as MessageRow[]; } ``` **Step 5: Run test to verify it passes** ```bash cd backend && bun test src/db/index.test.ts # Expected: PASS ``` **Step 6: Commit** ```bash git add backend/src/db/ git commit -m "add sqlite cache schema, thread/message storage" ``` --- ### Task 6: REST API Routes Expose threads and messages via REST endpoints. **Files:** - Create: `backend/src/routes/emails.ts` - Create: `backend/src/routes/emails.test.ts` - Modify: `backend/src/index.ts` **Step 1: Write the failing test** Create `backend/src/routes/emails.test.ts`: ```typescript import { describe, expect, test, beforeEach } from "bun:test"; import { Hono } from "hono"; import { emailRoutes } from "./emails"; import { createDatabase, insertThread, insertMessage } from "../db/index"; import type { Database } from "bun:sqlite"; let app: Hono; let db: Database; beforeEach(() => { db = createDatabase(":memory:"); app = new Hono(); app.route("/api", emailRoutes(db)); insertThread(db, { threadId: "t001", subject: "Q1 Planning", authors: "Alice, Bob", totalMessages: 2, tags: "inbox,unread", timestamp: 1709884532, accountId: "personal", }); insertMessage(db, { messageId: "msg001@example.com", threadId: "t001", fromHeader: "Alice ", toHeader: "user@example.com", subject: "Q1 Planning", date: "2024-03-08T10:15:32+01:00", inReplyTo: "", body: "Hey, let's plan Q1.", tags: "inbox,unread", timestamp: 1709884532, accountId: "personal", }); insertMessage(db, { messageId: "msg003@example.com", threadId: "t001", fromHeader: "Bob ", toHeader: "alice@example.com", subject: "Re: Q1 Planning", date: "2024-03-08T10:16:40+01:00", inReplyTo: "msg001@example.com", body: "Sounds good. Tuesday work?", tags: "inbox", timestamp: 1709884600, accountId: "personal", }); }); describe("GET /api/threads", () => { test("returns threads for an account", async () => { const res = await app.request("/api/threads?accountId=personal"); expect(res.status).toBe(200); const data = await res.json(); expect(data.threads).toHaveLength(1); expect(data.threads[0].subject).toBe("Q1 Planning"); }); }); describe("GET /api/threads/:threadId/messages", () => { test("returns messages in a thread sorted by time", async () => { const res = await app.request("/api/threads/t001/messages"); expect(res.status).toBe(200); const data = await res.json(); expect(data.messages).toHaveLength(2); expect(data.messages[0].messageId).toBe("msg001@example.com"); expect(data.messages[1].messageId).toBe("msg003@example.com"); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/routes/emails.test.ts # Expected: FAIL — emailRoutes not defined ``` **Step 3: Write implementation** Create `backend/src/routes/emails.ts`: ```typescript import { Hono } from "hono"; import type { Database } from "bun:sqlite"; import { getThreads, getMessagesForThread } from "../db/index"; export function emailRoutes(db: Database): Hono { const router = new Hono(); router.get("/threads", (c) => { const accountId = c.req.query("accountId"); if (!accountId) { return c.json({ error: "accountId query parameter required" }, 400); } const threads = getThreads(db, accountId); return c.json({ threads }); }); router.get("/threads/:threadId/messages", (c) => { const threadId = c.req.param("threadId"); const messages = getMessagesForThread(db, threadId); return c.json({ messages }); }); return router; } ``` **Step 4: Wire routes into main app** Update `backend/src/index.ts`: ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; import { emailRoutes } from "./routes/emails"; import { createDatabase } from "./db/index"; const db = createDatabase(process.env.DB_PATH ?? "magnumopus.db"); const app = new Hono(); app.use("*", cors()); app.get("/health", (c) => c.json({ status: "ok" })); app.route("/api", emailRoutes(db)); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch, }; ``` **Step 5: Run test to verify it passes** ```bash cd backend && bun test src/routes/emails.test.ts # Expected: PASS ``` **Step 6: Commit** ```bash git add backend/src/routes/ backend/src/index.ts git commit -m "add rest api routes for threads, messages" ``` --- ### Task 7: SSE Endpoint Real-time event stream for clients. **Files:** - Create: `backend/src/routes/events.ts` - Create: `backend/src/services/eventbus.ts` - Create: `backend/src/services/eventbus.test.ts` - Modify: `backend/src/index.ts` **Step 1: Write the failing test** Create `backend/src/services/eventbus.test.ts`: ```typescript import { describe, expect, test } from "bun:test"; import { EventBus } from "./eventbus"; describe("EventBus", () => { test("subscribers receive published events", () => { const bus = new EventBus(); const received: unknown[] = []; bus.subscribe((event) => received.push(event)); bus.publish({ type: "new_mail", threadId: "t001" }); expect(received).toHaveLength(1); expect(received[0]).toEqual({ type: "new_mail", threadId: "t001" }); }); test("unsubscribe stops receiving events", () => { const bus = new EventBus(); const received: unknown[] = []; const unsubscribe = bus.subscribe((event) => received.push(event)); bus.publish({ type: "a" }); unsubscribe(); bus.publish({ type: "b" }); expect(received).toHaveLength(1); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/services/eventbus.test.ts # Expected: FAIL — EventBus not defined ``` **Step 3: Write EventBus implementation** Create `backend/src/services/eventbus.ts`: ```typescript export type EventHandler = (event: Record) => void; export class EventBus { private subscribers = new Set(); subscribe(handler: EventHandler): () => void { this.subscribers.add(handler); return () => { this.subscribers.delete(handler); }; } publish(event: Record): void { for (const handler of this.subscribers) { handler(event); } } } ``` **Step 4: Run test to verify it passes** ```bash cd backend && bun test src/services/eventbus.test.ts # Expected: PASS ``` **Step 5: Create SSE route** Create `backend/src/routes/events.ts`: ```typescript import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import type { EventBus } from "../services/eventbus"; export function eventRoutes(bus: EventBus): Hono { const router = new Hono(); router.get("/events", (c) => { return streamSSE(c, async (stream) => { let id = 0; const unsubscribe = bus.subscribe(async (event) => { await stream.writeSSE({ data: JSON.stringify(event), event: event.type as string, id: String(id++), }); }); stream.onAbort(() => { unsubscribe(); }); // keep connection alive while (true) { await stream.sleep(30000); } }); }); return router; } ``` **Step 6: Wire into main app** Update `backend/src/index.ts`: ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; import { emailRoutes } from "./routes/emails"; import { eventRoutes } from "./routes/events"; import { createDatabase } from "./db/index"; import { EventBus } from "./services/eventbus"; const db = createDatabase(process.env.DB_PATH ?? "magnumopus.db"); const bus = new EventBus(); const app = new Hono(); app.use("*", cors()); app.get("/health", (c) => c.json({ status: "ok" })); app.route("/api", emailRoutes(db)); app.route("/api", eventRoutes(bus)); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch, }; ``` **Step 7: Commit** ```bash git add backend/src/services/eventbus.ts backend/src/services/eventbus.test.ts backend/src/routes/events.ts backend/src/index.ts git commit -m "add sse event bus, real-time event endpoint" ``` --- ### Task 8: Sync-to-Cache Pipeline After mbsync+notmuch run, populate the SQLite cache from notmuch query results. Publish events for new threads. **Files:** - Create: `backend/src/services/cache-sync.ts` - Create: `backend/src/services/cache-sync.test.ts` **Step 1: Write the failing test** Create `backend/src/services/cache-sync.test.ts`: ```typescript import { describe, expect, test, beforeEach } from "bun:test"; import { syncThreadsToCache } from "./cache-sync"; import { createDatabase, getThreads, getMessagesForThread } from "../db/index"; import { EventBus } from "./eventbus"; import type { ThreadSummary, Message } from "./notmuch"; import type { Database } from "bun:sqlite"; let db: Database; let bus: EventBus; beforeEach(() => { db = createDatabase(":memory:"); bus = new EventBus(); }); const mockThreads: ThreadSummary[] = [ { threadId: "t001", subject: "Q1 Planning", authors: "Alice, Bob", totalMessages: 2, tags: ["inbox", "unread"], timestamp: 1709884532, }, ]; const mockMessages: Message[] = [ { messageId: "msg001@example.com", from: "Alice ", to: "user@example.com", subject: "Q1 Planning", date: "Fri, 08 Mar 2024 10:15:32 +0100", inReplyTo: "", references: "", body: "Hey, let's plan Q1.\n", tags: ["inbox", "unread"], timestamp: 1709884532, }, { messageId: "msg003@example.com", from: "Bob ", to: "alice@example.com", subject: "Re: Q1 Planning", date: "Fri, 08 Mar 2024 10:16:40 +0100", inReplyTo: "msg001@example.com", references: "msg001@example.com", body: "Sounds good.\n", tags: ["inbox"], timestamp: 1709884600, }, ]; describe("syncThreadsToCache", () => { test("populates database from thread/message data", () => { const events: unknown[] = []; bus.subscribe((e) => events.push(e)); syncThreadsToCache(db, bus, "personal", mockThreads, new Map([["t001", mockMessages]])); const threads = getThreads(db, "personal"); expect(threads).toHaveLength(1); expect(threads[0].subject).toBe("Q1 Planning"); const messages = getMessagesForThread(db, "t001"); expect(messages).toHaveLength(2); expect(events.length).toBeGreaterThan(0); expect(events[0]).toEqual(expect.objectContaining({ type: "threads_updated" })); }); }); ``` **Step 2: Run test to verify it fails** ```bash cd backend && bun test src/services/cache-sync.test.ts # Expected: FAIL — syncThreadsToCache not defined ``` **Step 3: Write implementation** Create `backend/src/services/cache-sync.ts`: ```typescript import type { Database } from "bun:sqlite"; import type { EventBus } from "./eventbus"; import type { ThreadSummary, Message } from "./notmuch"; import { insertThread, insertMessage } from "../db/index"; export function syncThreadsToCache( db: Database, bus: EventBus, accountId: string, threads: ThreadSummary[], messagesMap: Map, ): void { const transaction = db.transaction(() => { for (const thread of threads) { insertThread(db, { threadId: thread.threadId, subject: thread.subject, authors: thread.authors, totalMessages: thread.totalMessages, tags: thread.tags.join(","), timestamp: thread.timestamp, accountId, }); const messages = messagesMap.get(thread.threadId) ?? []; for (const msg of messages) { insertMessage(db, { messageId: msg.messageId, threadId: thread.threadId, fromHeader: msg.from, toHeader: msg.to, subject: msg.subject, date: msg.date, inReplyTo: msg.inReplyTo, body: msg.body, tags: msg.tags.join(","), timestamp: msg.timestamp, accountId, }); } } }); transaction(); bus.publish({ type: "threads_updated", accountId, threadCount: threads.length, }); } ``` **Step 4: Run test to verify it passes** ```bash cd backend && bun test src/services/cache-sync.test.ts # Expected: PASS ``` **Step 5: Commit** ```bash git add backend/src/services/cache-sync.ts backend/src/services/cache-sync.test.ts git commit -m "add sync-to-cache pipeline, publish events on new threads" ``` --- ### Task 9: Sync Trigger Endpoint REST endpoint to trigger a full sync (mbsync → notmuch → cache). Used by clients and cron. **Files:** - Create: `backend/src/routes/sync.ts` - Modify: `backend/src/index.ts` **Step 1: Write implementation** Create `backend/src/routes/sync.ts`: ```typescript import { Hono } from "hono"; import type { Database } from "bun:sqlite"; import type { EventBus } from "../services/eventbus"; import { syncAndIndex } from "../services/orchestrator"; import { searchThreads, getThread } from "../services/notmuch"; import { syncThreadsToCache } from "../services/cache-sync"; export function syncRoutes(db: Database, bus: EventBus): Hono { const router = new Hono(); router.post("/sync", async (c) => { const { accountId, channelName } = await c.req.json<{ accountId: string; channelName: string; }>(); const syncResult = await syncAndIndex(channelName); if (syncResult.exitCode !== 0) { return c.json({ error: "sync failed", stderr: syncResult.stderr, }, 500); } const threads = await searchThreads("tag:inbox"); const messagesMap = new Map>>(); for (const thread of threads) { const messages = await getThread(thread.threadId); messagesMap.set(thread.threadId, messages); } syncThreadsToCache(db, bus, accountId, threads, messagesMap); return c.json({ status: "ok", threadsProcessed: threads.length, }); }); return router; } ``` **Step 2: Wire into main app** Update `backend/src/index.ts`: ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; import { emailRoutes } from "./routes/emails"; import { eventRoutes } from "./routes/events"; import { syncRoutes } from "./routes/sync"; import { createDatabase } from "./db/index"; import { EventBus } from "./services/eventbus"; const db = createDatabase(process.env.DB_PATH ?? "magnumopus.db"); const bus = new EventBus(); const app = new Hono(); app.use("*", cors()); app.get("/health", (c) => c.json({ status: "ok" })); app.route("/api", emailRoutes(db)); app.route("/api", eventRoutes(bus)); app.route("/api", syncRoutes(db, bus)); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch, }; ``` **Step 3: Commit** ```bash git add backend/src/routes/sync.ts backend/src/index.ts git commit -m "add sync trigger endpoint, wire mbsync → notmuch → cache" ``` --- ## Phase 2: macOS Client ### Task 10: Xcode Project Setup **Files:** - Create: `clients/macos/MagnumOpus.xcodeproj` (via Xcode) - Create: `clients/macos/MagnumOpus/MagnumOpusApp.swift` - Create: `clients/macos/MagnumOpus/ContentView.swift` **Step 1: Create Xcode project** Open Xcode → File → New → Project: - Platform: macOS - Template: App - Product Name: MagnumOpus - Team: `NG5W75WE8U` - Organization Identifier: `de.felixfoertsch` - Interface: SwiftUI - Language: Swift - Storage: None - Testing System: Swift Testing - Save to: `/Users/felixfoertsch/Developer/MagnumOpus/clients/macos/` **Step 2: Verify it builds and runs** Build and run (Cmd+R). Expect a blank window with "Hello, world!" **Step 3: Commit** ```bash git add clients/ git commit -m "scaffold macos swiftui project" ``` --- ### Task 11: API Client Swift networking layer to talk to the backend. **Files:** - Create: `clients/macos/MagnumOpus/Services/APIClient.swift` - Create: `clients/macos/MagnumOpusTests/APIClientTests.swift` **Step 1: Write the failing test** Create `clients/macos/MagnumOpusTests/APIClientTests.swift`: ```swift import Testing @testable import MagnumOpus @Suite("APIClient") struct APIClientTests { @Test("decodes thread list from JSON") func decodesThreadList() throws { let json = """ { "threads": [ { "threadId": "t001", "subject": "Q1 Planning", "authors": "Alice, Bob", "totalMessages": 3, "tags": "inbox,unread", "timestamp": 1709884532, "accountId": "personal" } ] } """.data(using: .utf8)! let response = try JSONDecoder().decode(ThreadListResponse.self, from: json) #expect(response.threads.count == 1) #expect(response.threads[0].subject == "Q1 Planning") } @Test("decodes message list from JSON") func decodesMessageList() throws { let json = """ { "messages": [ { "messageId": "msg001@example.com", "threadId": "t001", "fromHeader": "Alice ", "toHeader": "user@example.com", "subject": "Q1 Planning", "date": "2024-03-08T10:15:32+01:00", "inReplyTo": "", "body": "Hey, let's plan Q1.", "tags": "inbox,unread", "timestamp": 1709884532, "accountId": "personal" } ] } """.data(using: .utf8)! let response = try JSONDecoder().decode(MessageListResponse.self, from: json) #expect(response.messages.count == 1) #expect(response.messages[0].body == "Hey, let's plan Q1.") } } ``` **Step 2: Run test to verify it fails** Cmd+U in Xcode. Expected: compile error — types not defined. **Step 3: Write implementation** Create `clients/macos/MagnumOpus/Services/APIClient.swift`: ```swift import Foundation struct ThreadSummary: Codable, Identifiable { let threadId: String let subject: String let authors: String let totalMessages: Int let tags: String let timestamp: Int let accountId: String var id: String { threadId } var tagList: [String] { tags.split(separator: ",").map(String.init) } var isUnread: Bool { tagList.contains("unread") } var date: Date { Date(timeIntervalSince1970: TimeInterval(timestamp)) } } struct EmailMessage: Codable, Identifiable { let messageId: String let threadId: String let fromHeader: String let toHeader: String let subject: String let date: String let inReplyTo: String let body: String let tags: String let timestamp: Int let accountId: String var id: String { messageId } var senderName: String { if let range = fromHeader.range(of: " <") { return String(fromHeader[.. [ThreadSummary] { var components = URLComponents( url: baseURL.appendingPathComponent("api/threads"), resolvingAgainstBaseURL: false )! components.queryItems = [URLQueryItem(name: "accountId", value: accountId)] let (data, _) = try await URLSession.shared.data(from: components.url!) return try JSONDecoder().decode(ThreadListResponse.self, from: data).threads } func fetchMessages(threadId: String) async throws -> [EmailMessage] { let url = baseURL.appendingPathComponent("api/threads/\(threadId)/messages") let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(MessageListResponse.self, from: data).messages } } ``` **Step 4: Run test to verify it passes** Cmd+U in Xcode. Expected: PASS. **Step 5: Commit** ```bash git add clients/ git commit -m "add swift api client with model types, json decoding" ``` --- ### Task 12: SSE Client Receive real-time events from the backend. **Files:** - Create: `clients/macos/MagnumOpus/Services/SSEClient.swift` **Step 1: Write implementation** Create `clients/macos/MagnumOpus/Services/SSEClient.swift`: ```swift import Foundation struct ServerEvent { let id: String? let event: String? let data: String } @Observable final class SSEClient { private let url: URL private var task: Task? var lastEvent: ServerEvent? init(url: URL) { self.url = url } func connect(onEvent: @escaping (ServerEvent) -> Void) { task = Task { do { var request = URLRequest(url: url) request.setValue("text/event-stream", forHTTPHeaderField: "Accept") let (bytes, _) = try await URLSession.shared.bytes(for: request) var currentEvent: String? var currentData = "" var currentId: String? for try await line in bytes.lines { if line.isEmpty { if !currentData.isEmpty { let event = ServerEvent( id: currentId, event: currentEvent, data: currentData.trimmingCharacters(in: .newlines) ) self.lastEvent = event onEvent(event) currentEvent = nil currentData = "" currentId = nil } } else if line.hasPrefix("data: ") { currentData += String(line.dropFirst(6)) + "\n" } else if line.hasPrefix("event: ") { currentEvent = String(line.dropFirst(7)) } else if line.hasPrefix("id: ") { currentId = String(line.dropFirst(4)) } } } catch { if !Task.isCancelled { try? await Task.sleep(for: .seconds(5)) connect(onEvent: onEvent) } } } } func disconnect() { task?.cancel() task = nil } } ``` **Step 2: Commit** ```bash git add clients/ git commit -m "add sse client for real-time backend events" ``` --- ### Task 13: Three-Column Layout The main window with sidebar, item list, and detail view. **Files:** - Create: `clients/macos/MagnumOpus/Views/SidebarView.swift` - Create: `clients/macos/MagnumOpus/Views/ThreadListView.swift` - Create: `clients/macos/MagnumOpus/Views/ThreadDetailView.swift` - Create: `clients/macos/MagnumOpus/ViewModels/MailViewModel.swift` - Modify: `clients/macos/MagnumOpus/ContentView.swift` **Step 1: Create the ViewModel** Create `clients/macos/MagnumOpus/ViewModels/MailViewModel.swift`: ```swift import Foundation @Observable final class MailViewModel { let apiClient: APIClient var threads: [ThreadSummary] = [] var selectedThread: ThreadSummary? var selectedMessages: [EmailMessage] = [] var selectedPerspective: Perspective = .inbox var isLoading = false var errorMessage: String? enum Perspective: String, CaseIterable, Identifiable { case inbox = "Inbox" case today = "Today" case archive = "Archive" var id: String { rawValue } var systemImage: String { switch self { case .inbox: "tray" case .today: "sun.max" case .archive: "archivebox" } } } init(apiClient: APIClient) { self.apiClient = apiClient } func loadThreads(accountId: String) async { isLoading = true errorMessage = nil do { threads = try await apiClient.fetchThreads(accountId: accountId) } catch { errorMessage = error.localizedDescription } isLoading = false } func loadMessages(for thread: ThreadSummary) async { selectedThread = thread do { selectedMessages = try await apiClient.fetchMessages(threadId: thread.threadId) } catch { errorMessage = error.localizedDescription } } } ``` **Step 2: Create SidebarView** Create `clients/macos/MagnumOpus/Views/SidebarView.swift`: ```swift import SwiftUI struct SidebarView: View { @Bindable var viewModel: MailViewModel var body: some View { List(selection: $viewModel.selectedPerspective) { Section("Views") { ForEach(MailViewModel.Perspective.allCases) { perspective in Label(perspective.rawValue, systemImage: perspective.systemImage) .tag(perspective) } } } .navigationTitle("Magnum Opus") .listStyle(.sidebar) } } ``` **Step 3: Create ThreadListView** Create `clients/macos/MagnumOpus/Views/ThreadListView.swift`: ```swift import SwiftUI struct ThreadListView: View { @Bindable var viewModel: MailViewModel var body: some View { List(viewModel.threads, selection: Binding( get: { viewModel.selectedThread?.threadId }, set: { newId in if let thread = viewModel.threads.first(where: { $0.threadId == newId }) { Task { await viewModel.loadMessages(for: thread) } } } )) { thread in ThreadRow(thread: thread) .tag(thread.threadId) } .listStyle(.inset) .navigationTitle(viewModel.selectedPerspective.rawValue) .overlay { if viewModel.isLoading { ProgressView() } else if viewModel.threads.isEmpty { ContentUnavailableView( "No Messages", systemImage: "tray", description: Text("Inbox is empty") ) } } } } struct ThreadRow: View { let thread: ThreadSummary var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(thread.authors) .fontWeight(thread.isUnread ? .bold : .regular) .lineLimit(1) Spacer() Text(thread.date, style: .relative) .font(.caption) .foregroundStyle(.secondary) } Text(thread.subject) .font(.subheadline) .lineLimit(1) if thread.totalMessages > 1 { Text("\(thread.totalMessages) messages") .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 2) } } ``` **Step 4: Create ThreadDetailView** Create `clients/macos/MagnumOpus/Views/ThreadDetailView.swift`: ```swift import SwiftUI struct ThreadDetailView: View { let thread: ThreadSummary? let messages: [EmailMessage] var body: some View { Group { if let thread { ScrollView { VStack(alignment: .leading, spacing: 0) { Text(thread.subject) .font(.title2) .fontWeight(.semibold) .padding() Divider() ForEach(messages) { message in MessageView(message: message) Divider() } } } } else { ContentUnavailableView( "No Thread Selected", systemImage: "envelope", description: Text("Select a thread to read") ) } } } } struct MessageView: View { let message: EmailMessage var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(message.senderName) .fontWeight(.semibold) Spacer() Text(message.date) .font(.caption) .foregroundStyle(.secondary) } Text("To: \(message.toHeader)") .font(.caption) .foregroundStyle(.secondary) Text(message.body) .font(.body) .textSelection(.enabled) } .padding() } } ``` **Step 5: Wire into ContentView** Replace `clients/macos/MagnumOpus/ContentView.swift`: ```swift import SwiftUI struct ContentView: View { @State private var viewModel: MailViewModel init() { let baseURL = URL(string: "http://localhost:3000")! let client = APIClient(baseURL: baseURL) _viewModel = State(initialValue: MailViewModel(apiClient: client)) } var body: some View { NavigationSplitView { SidebarView(viewModel: viewModel) } content: { ThreadListView(viewModel: viewModel) } detail: { ThreadDetailView( thread: viewModel.selectedThread, messages: viewModel.selectedMessages ) } .task { await viewModel.loadThreads(accountId: "personal") } } } ``` **Step 6: Build and verify** Build and run (Cmd+R). Expect the three-column layout with empty states. If the backend is running with data, threads should appear. **Step 7: Commit** ```bash git add clients/ git commit -m "add three-column layout with sidebar, thread list, detail view" ``` --- ### Task 14: SSE Integration Connect the macOS client to the SSE stream so it auto-refreshes when new mail arrives. **Files:** - Modify: `clients/macos/MagnumOpus/ViewModels/MailViewModel.swift` - Modify: `clients/macos/MagnumOpus/ContentView.swift` **Step 1: Add SSE handling to ViewModel** Add to `MailViewModel.swift`: ```swift func connectToEvents(baseURL: URL, accountId: String) { let sseURL = baseURL.appendingPathComponent("api/events") let sseClient = SSEClient(url: sseURL) sseClient.connect { [weak self] event in guard let self else { return } if event.event == "threads_updated" { Task { @MainActor in await self.loadThreads(accountId: accountId) } } } } ``` **Step 2: Call from ContentView** Add to the `.task` modifier in `ContentView`: ```swift .task { await viewModel.loadThreads(accountId: "personal") viewModel.connectToEvents( baseURL: URL(string: "http://localhost:3000")!, accountId: "personal" ) } ``` **Step 3: Commit** ```bash git add clients/ git commit -m "connect macos client to sse for live updates" ``` --- ## Phase 3: End-to-End Verification ### Task 15: Development Scripts **Files:** - Create: `scripts/dev-setup.sh` - Create: `scripts/dev-sync.sh` **Step 1: Create dev setup script** Create `scripts/dev-setup.sh`: ```bash #!/usr/bin/env bash set -euo pipefail echo "=== Magnum Opus Development Setup ===" for cmd in mbsync notmuch bun; do if ! command -v "$cmd" &>/dev/null; then echo "ERROR: $cmd is not installed." echo " brew install isync notmuch bun" exit 1 fi done echo "All dependencies found." MAIL_DIR="${HOME}/Mail/magnumopus-dev" mkdir -p "$MAIL_DIR" echo "" echo "Next steps:" echo " 1. Configure your IMAP account in backend/.env" echo " 2. Run: cd backend && bun run dev" echo " 3. Open clients/macos/MagnumOpus.xcodeproj in Xcode" echo " 4. Run: bash scripts/dev-sync.sh (to trigger first sync)" ``` **Step 2: Create dev sync trigger** Create `scripts/dev-sync.sh`: ```bash #!/usr/bin/env bash set -euo pipefail API_URL="${API_URL:-http://localhost:3000}" ACCOUNT_ID="${ACCOUNT_ID:-personal}" CHANNEL_NAME="${CHANNEL_NAME:-personal}" echo "Triggering sync..." curl -s -X POST "${API_URL}/api/sync" \ -H "Content-Type: application/json" \ -d "{\"accountId\": \"${ACCOUNT_ID}\", \"channelName\": \"${CHANNEL_NAME}\"}" echo "" echo "Done." ``` **Step 3: Make scripts executable and commit** ```bash chmod +x scripts/dev-setup.sh scripts/dev-sync.sh git add scripts/ git commit -m "add dev setup, sync trigger scripts" ``` --- ### Task 16: Run All Tests and Verify **Step 1: Run backend tests** ```bash cd backend && bun test # Expected: all tests pass ``` **Step 2: Run macOS tests** In Xcode: Product → Test (Cmd+U) Expected: all tests pass **Step 3: Manual integration test** 1. Start backend: `cd backend && bun run dev` 2. Configure mbsync for a real account (or create test Maildir manually) 3. Run sync: `bash scripts/dev-sync.sh` 4. Open macOS app in Xcode, build and run 5. Verify threads appear in the list 6. Click a thread, verify messages appear in detail view --- ## Future Phases (not in this plan) These are documented in `docs/plans/2026-03-10-magnum-opus-design.md` and will get their own implementation plans: - **v0.2:** Triage actions (archive, discard, flag) with keyboard shortcuts - **v0.3:** Task creation (VTODO), vdirsyncer + CalDAV integration - **v0.4:** Unified inbox (emails + tasks interleaved) - **v0.5:** Defer (date, time, context, combination) - **v0.6:** Delegation with SMTP send, waiting loop with auto-resurfacing - **v0.7:** Project views, dependency-driven visibility - **v0.8:** Calendar/forecast view - **v0.9:** iOS client - **v1.0:** Polish, deployment to Uberspace, first real usage