Files
MagnumOpus/docs/plans/2026-03-10-v0.1-implementation-plan.md
2026-03-10 10:59:01 +01:00

50 KiB

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

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:

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

{
	"scripts": {
		"dev": "bun --watch src/index.ts",
		"start": "bun src/index.ts",
		"test": "bun test"
	}
}

Step 6: Verify it runs

cd backend && bun run dev
# In another terminal:
curl http://localhost:3000/health
# Expected: {"status":"ok"}

Step 7: Commit

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:

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

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:

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

cd backend && bun test src/services/sync.test.ts
# Expected: PASS

Step 5: Commit

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:

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

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:

export interface CommandResult {
	stdout: string;
	stderr: string;
	exitCode: number;
}

export async function runCommand(cmd: string, args: string[]): Promise<CommandResult> {
	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<CommandResult> {
	return runCommand("mbsync", [channelName]);
}

export async function indexMail(): Promise<CommandResult> {
	return runCommand("notmuch", ["new"]);
}

export async function syncAndIndex(channelName: string): Promise<CommandResult> {
	const syncResult = await syncMail(channelName);
	if (syncResult.exitCode !== 0) {
		return syncResult;
	}
	return indexMail();
}

Step 4: Run test to verify it passes

cd backend && bun test src/services/orchestrator.test.ts
# Expected: PASS (only testing runCommand with echo/false, not mbsync/notmuch)

Step 5: Commit

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:

[
	{
		"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:

[
	[
		[
			{
				"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 <alice@example.com>",
					"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 <bob@example.com>",
							"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:

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 <alice@example.com>");
		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

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:

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<Record<string, unknown>>).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<string, string>;
			const bodyParts = item.body as Array<Record<string, unknown>>;
			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<string, unknown> {
	return typeof item === "object" && item !== null && "id" in item && "headers" in item;
}

export async function searchThreads(query: string): Promise<ThreadSummary[]> {
	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<Message[]> {
	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

cd backend && bun test src/services/notmuch.test.ts
# Expected: PASS

Step 6: Commit

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:

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 <alice@example.com>",
			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

cd backend && bun test src/db/index.test.ts
# Expected: FAIL — createDatabase not defined

Step 3: Create schema

Create backend/src/db/schema.ts:

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:

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

cd backend && bun test src/db/index.test.ts
# Expected: PASS

Step 6: Commit

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:

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 <alice@example.com>",
		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 <bob@example.com>",
		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

cd backend && bun test src/routes/emails.test.ts
# Expected: FAIL — emailRoutes not defined

Step 3: Write implementation

Create backend/src/routes/emails.ts:

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:

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

cd backend && bun test src/routes/emails.test.ts
# Expected: PASS

Step 6: Commit

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:

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

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:

export type EventHandler = (event: Record<string, unknown>) => void;

export class EventBus {
	private subscribers = new Set<EventHandler>();

	subscribe(handler: EventHandler): () => void {
		this.subscribers.add(handler);
		return () => {
			this.subscribers.delete(handler);
		};
	}

	publish(event: Record<string, unknown>): void {
		for (const handler of this.subscribers) {
			handler(event);
		}
	}
}

Step 4: Run test to verify it passes

cd backend && bun test src/services/eventbus.test.ts
# Expected: PASS

Step 5: Create SSE route

Create backend/src/routes/events.ts:

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:

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

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:

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 <alice@example.com>",
		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 <bob@example.com>",
		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

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:

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<string, Message[]>,
): 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

cd backend && bun test src/services/cache-sync.test.ts
# Expected: PASS

Step 5: Commit

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:

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<string, Awaited<ReturnType<typeof getThread>>>();
		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:

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

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

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:

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 <alice@example.com>",
					"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:

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[..<range.lowerBound])
		}
		return fromHeader
	}
}

struct ThreadListResponse: Codable {
	let threads: [ThreadSummary]
}

struct MessageListResponse: Codable {
	let messages: [EmailMessage]
}

@Observable
final class APIClient {
	private let baseURL: URL

	init(baseURL: URL) {
		self.baseURL = baseURL
	}

	func fetchThreads(accountId: String) async throws -> [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

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:

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<Void, Never>?

	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

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:

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:

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:

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:

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:

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

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:

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:

.task {
	await viewModel.loadThreads(accountId: "personal")
	viewModel.connectToEvents(
		baseURL: URL(string: "http://localhost:3000")!,
		accountId: "personal"
	)
}

Step 3: Commit

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:

#!/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:

#!/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

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

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