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