diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts
new file mode 100644
index 00000000..f42c2baa
--- /dev/null
+++ b/app/api/dev-jmap/[...path]/route.ts
@@ -0,0 +1,945 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Mock JMAP server for local development.
+ *
+ * Enabled only when DEV_MOCK_JMAP=true. Provides realistic dummy data
+ * so the UI can be developed without a real JMAP mail server.
+ *
+ * Accepts any username/password — no real authentication.
+ */
+
+const ACCOUNT_ID = 'dev-account-001';
+
+// ---------------------------------------------------------------------------
+// Mailboxes
+// ---------------------------------------------------------------------------
+
+interface MockMailbox {
+ id: string;
+ name: string;
+ role: string | null;
+ sortOrder: number;
+ totalEmails: number;
+ unreadEmails: number;
+}
+
+interface MockEmail {
+ id: string;
+ threadId: string;
+ mailboxIds: Record;
+ keywords: Record;
+ size: number;
+ receivedAt: string;
+ from: { name: string; email: string }[];
+ to: { name: string; email: string }[];
+ cc: { name: string; email: string }[];
+ subject: string;
+ preview: string;
+ hasAttachment: boolean;
+ textBody: { partId: string; blobId: string; size: number; type: string }[];
+ htmlBody: { partId: string; blobId: string; size: number; type: string }[];
+ bodyValues: Record;
+ attachments?: { partId: string; blobId: string; size: number; name: string; type: string }[];
+}
+
+let stateCounter = 1;
+function nextState(): string {
+ return `mock-state-${++stateCounter}`;
+}
+
+const mailboxes: MockMailbox[] = [
+ { id: 'mb-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 5, unreadEmails: 2 },
+ { id: 'mb-drafts', name: 'Drafts', role: 'drafts', sortOrder: 2, totalEmails: 1, unreadEmails: 0 },
+ { id: 'mb-sent', name: 'Sent', role: 'sent', sortOrder: 3, totalEmails: 3, unreadEmails: 0 },
+ { id: 'mb-junk', name: 'Junk', role: 'junk', sortOrder: 4, totalEmails: 1, unreadEmails: 1 },
+ { id: 'mb-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 0, unreadEmails: 0 },
+ { id: 'mb-archive', name: 'Archive', role: 'archive', sortOrder: 6, totalEmails: 2, unreadEmails: 0 },
+];
+
+function recomputeMailboxCounts(): void {
+ for (const mb of mailboxes) {
+ mb.totalEmails = emails.filter((e) => e.mailboxIds[mb.id]).length;
+ mb.unreadEmails = emails.filter((e) => e.mailboxIds[mb.id] && !e.keywords.$seen).length;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Email fixtures
+// ---------------------------------------------------------------------------
+
+function daysAgo(n: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ return d.toISOString();
+}
+
+const emails: MockEmail[] = [
+ {
+ id: 'email-001',
+ threadId: 'thread-001',
+ mailboxIds: { 'mb-inbox': true },
+ keywords: {},
+ size: 4200,
+ receivedAt: daysAgo(0),
+ from: [{ name: 'Alice Johnson', email: 'alice@example.com' }],
+ to: [{ name: 'Dev User', email: 'dev@localhost' }],
+ cc: [],
+ subject: 'Welcome to JMAP Webmail!',
+ preview: 'Hi there! This is a sample email to help you get started with the JMAP Webmail development environment.',
+ hasAttachment: false,
+ textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }],
+ bodyValues: {
+ p1: { value: 'Hi there!\n\nThis is a sample email to help you get started with the JMAP Webmail development environment.\n\nFeel free to explore the UI — all data here is mock data.\n\nBest,\nAlice' },
+ p2: { value: '
Hi there!
This is a sample email to help you get started with the JMAP Webmail development environment.
Feel free to explore the UI — all data here is mock data.
Best, Alice
' },
+ },
+ },
+ {
+ id: 'email-002',
+ threadId: 'thread-002',
+ mailboxIds: { 'mb-inbox': true },
+ keywords: { $seen: true, $flagged: true },
+ size: 5100,
+ receivedAt: daysAgo(1),
+ from: [{ name: 'Bob Smith', email: 'bob@example.org' }],
+ to: [{ name: 'Dev User', email: 'dev@localhost' }],
+ cc: [{ name: 'Charlie Brown', email: 'charlie@example.net' }],
+ subject: 'Project Update — Q1 Review',
+ preview: 'Hey team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.',
+ hasAttachment: true,
+ textBody: [{ partId: 'p1', blobId: 'blob-003', size: 640, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 820, type: 'text/html' }],
+ bodyValues: {
+ p1: { value: 'Hey team,\n\nI wanted to share the latest project numbers. We are on track to meet our targets for Q1.\n\nKey highlights:\n- Revenue up 12%\n- New signups increased by 8%\n- Customer satisfaction at 94%\n\nLet me know if you have questions.\n\nBob' },
+ p2: { value: '
Hey team,
I wanted to share the latest project numbers. We are on track to meet our targets for Q1.
Revenue up 12%
New signups increased by 8%
Customer satisfaction at 94%
Let me know if you have questions.
Bob
' },
+ },
+ attachments: [
+ { partId: 'att1', blobId: 'blob-att-001', size: 24500, name: 'Q1-Report.pdf', type: 'application/pdf' },
+ ],
+ },
+ {
+ id: 'email-003',
+ threadId: 'thread-003',
+ mailboxIds: { 'mb-inbox': true },
+ keywords: { $seen: true },
+ size: 3100,
+ receivedAt: daysAgo(2),
+ from: [{ name: 'Carol Davis', email: 'carol@example.com' }],
+ to: [{ name: 'Dev User', email: 'dev@localhost' }],
+ cc: [],
+ subject: 'Lunch tomorrow?',
+ preview: 'Hey! Are you free for lunch tomorrow? I was thinking we could try that new place downtown.',
+ hasAttachment: false,
+ textBody: [{ partId: 'p1', blobId: 'blob-005', size: 180, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 260, type: 'text/html' }],
+ bodyValues: {
+ p1: { value: 'Hey!\n\nAre you free for lunch tomorrow? I was thinking we could try that new place downtown.\n\nLet me know!\nCarol' },
+ p2: { value: '
Hey!
Are you free for lunch tomorrow? I was thinking we could try that new place downtown.
Let me know! Carol
' },
+ },
+ },
+ {
+ id: 'email-004',
+ threadId: 'thread-004',
+ mailboxIds: { 'mb-inbox': true },
+ keywords: {},
+ size: 6200,
+ receivedAt: daysAgo(0),
+ from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }],
+ to: [{ name: 'Dev User', email: 'dev@localhost' }],
+ cc: [],
+ subject: '[jmap-webmail] New issue: Add dark mode toggle (#42)',
+ preview: 'A new issue has been opened by @contributor. It would be great to have a dark mode toggle in the settings panel.',
+ hasAttachment: false,
+ textBody: [{ partId: 'p1', blobId: 'blob-007', size: 350, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 500, type: 'text/html' }],
+ bodyValues: {
+ p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n—\nReply to this email directly or view it on GitHub.' },
+ p2: { value: '
A new issue has been opened by @contributor.
Add dark mode toggle
It would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.
Reply to this email directly or view it on GitHub.
' },
+ },
+ },
+ // Newsletter with full HTML
+ {
+ id: 'email-013',
+ threadId: 'thread-012',
+ mailboxIds: { 'mb-inbox': true },
+ keywords: {},
+ size: 18200,
+ receivedAt: daysAgo(0),
+ from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }],
+ to: [{ name: 'Dev User', email: 'dev@localhost' }],
+ cc: [],
+ subject: 'Launchpad Weekly #47 — The future of the open web',
+ preview: 'This week: WebAssembly Components hit 1.0, a deep dive into privacy-first analytics, and 5 tools we can\'t stop using.',
+ hasAttachment: false,
+ textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1200, type: 'text/plain' }],
+ htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 16000, type: 'text/html' }],
+ bodyValues: {
+ p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 — lightning-fast builds\n2. Biome — unified lint + format\n3. Deno 4 — batteries included runtime\n4. TailwindCSS 4 — zero config styling\n5. Playwright — end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' },
+ p2: { value: '
◆ LAUNCHPAD WEEKLY ISSUE #47 • MARCH 2026
The future of the open web
WebAssembly Components hit 1.0, privacy-first analytics take center stage, and 5 tools we can’t stop using.
FEATURED
WebAssembly Components hit 1.0
The Component Model specification has officially reached 1.0, unlocking language-agnostic modules that compose and run anywhere — from the browser to the edge. This is a watershed moment for portable computing.
Cookie banners are on their way out. We explore the next generation of analytics platforms that respect user privacy by design — no consent dialogs required. From server-side aggregation to differential privacy, the landscape is shifting fast.