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
- Start backend:
cd backend && bun run dev - Configure mbsync for a real account (or create test Maildir manually)
- Run sync:
bash scripts/dev-sync.sh - Open macOS app in Xcode, build and run
- Verify threads appear in the list
- 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