From 4432d1f711c5a0cabe8358769b1f6b4f5f032941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 10 Mar 2026 10:59:01 +0100 Subject: [PATCH] add v0.1 implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-10-v0.1-implementation-plan.md | 2129 +++++++++++++++++ 1 file changed, 2129 insertions(+) create mode 100644 docs/plans/2026-03-10-v0.1-implementation-plan.md diff --git a/docs/plans/2026-03-10-v0.1-implementation-plan.md b/docs/plans/2026-03-10-v0.1-implementation-plan.md new file mode 100644 index 0000000..1ee6c8c --- /dev/null +++ b/docs/plans/2026-03-10-v0.1-implementation-plan.md @@ -0,0 +1,2129 @@ +# 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