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.

' }, }, }, { id: 'email-005', threadId: 'thread-005', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true }, size: 2800, receivedAt: daysAgo(4), from: [{ name: 'Newsletter', email: 'news@techdigest.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Your Weekly Tech Digest', preview: 'This week in tech: new JavaScript runtime benchmarks, WebAssembly reaches 3.0, and more.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-009', size: 900, type: 'text/plain' }], htmlBody: [{ partId: 'p2', blobId: 'blob-010', size: 1400, type: 'text/html' }], bodyValues: { p1: { value: 'This week in tech:\n\n1. New JavaScript runtime benchmarks show 30% improvement\n2. WebAssembly reaches version 3.0\n3. CSS container queries gain full browser support\n4. TypeScript 6.0 release candidate announced\n\nRead more at techdigest.example' }, p2: { value: '

Your Weekly Tech Digest

  1. New JavaScript runtime benchmarks show 30% improvement
  2. WebAssembly reaches version 3.0
  3. CSS container queries gain full browser support
  4. TypeScript 6.0 release candidate announced

Read more at techdigest.example

' }, }, }, // 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.

Read the deep dive →
ANALYSIS

Deep dive: Privacy-first analytics

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.

Explore the guide →
TOOLBOX

5 tools we can’t stop using

1Vite 7
Lightning-fast builds with zero-config ESM support.
2Biome
Unified linting and formatting in a single blazing-fast tool.
3Deno 4
Batteries-included runtime with native TypeScript & npm compat.
4TailwindCSS 4
Zero-config utility-first CSS that just works.
5Playwright
Reliable end-to-end testing across every browser.

You received this because you subscribed at launchpad.example

UnsubscribeManage preferencesView in browser

' }, }, }, // Sent { id: 'email-006', threadId: 'thread-003', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1800, receivedAt: daysAgo(2), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Carol Davis', email: 'carol@example.com' }], cc: [], subject: 'Re: Lunch tomorrow?', preview: 'Sounds great! Let\'s meet at noon.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Sounds great! Let\'s meet at noon.\n\n— Dev User' }, }, }, { id: 'email-007', threadId: 'thread-006', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 2200, receivedAt: daysAgo(3), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Bob Smith', email: 'bob@example.org' }], cc: [], subject: 'Re: Project Update — Q1 Review', preview: 'Thanks Bob, the numbers look great. I\'ll prepare the board presentation.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Thanks Bob, the numbers look great. I\'ll prepare the board presentation.\n\nCheers,\nDev User' }, }, }, { id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Alice Johnson', email: 'alice@example.com' }], cc: [], subject: 'Design review feedback', preview: 'Hi Alice, I reviewed the new mockups and have a few suggestions.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-013', size: 300, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Hi Alice,\n\nI reviewed the new mockups and have a few suggestions:\n\n1. The sidebar could use more contrast\n2. Consider adding breadcrumbs to the settings page\n3. The compose button placement looks good\n\nOverall great work!\n\nDev User' }, }, }, // Draft { id: 'email-009', threadId: 'thread-008', mailboxIds: { 'mb-drafts': true }, keywords: { $draft: true }, size: 1200, receivedAt: daysAgo(0), from: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Team', email: 'team@example.com' }], cc: [], subject: 'Meeting notes (draft)', preview: 'Notes from today\'s standup meeting...', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-014', size: 200, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Notes from today\'s standup meeting:\n\n- TODO: fill in details\n- Action items: ...' }, }, }, // Junk { id: 'email-010', threadId: 'thread-009', mailboxIds: { 'mb-junk': true }, keywords: {}, size: 4500, receivedAt: daysAgo(1), from: [{ name: 'Totally Real Prince', email: 'prince@scam.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'You have won $1,000,000!!!', preview: 'Congratulations! You have been selected as the winner of our international lottery.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-015', size: 500, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Congratulations!\n\nYou have been selected as the winner of our international lottery. To claim your prize, please send your bank details to...\n\n(This is mock spam for development purposes.)' }, }, }, // Archive { id: 'email-011', threadId: 'thread-010', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true }, size: 3800, receivedAt: daysAgo(14), from: [{ name: 'HR Department', email: 'hr@company.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Updated PTO Policy', preview: 'Please review the updated paid time off policy effective next month.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Hi team,\n\nPlease review the updated paid time off policy effective next month. Key changes include:\n\n- Increased annual leave by 2 days\n- New flexible Friday policy\n- Simplified approval workflow\n\nFull details in the employee handbook.\n\nBest,\nHR Department' }, }, }, { id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30), from: [{ name: 'Alice Johnson', email: 'alice@example.com' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Conference talk accepted!', preview: 'Great news — your talk proposal for the JMAP Conf has been accepted!', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-017', size: 350, type: 'text/plain' }], htmlBody: [], bodyValues: { p1: { value: 'Great news!\n\nYour talk proposal "Building Modern Webmail with JMAP" for the JMAP Conf has been accepted!\n\nThe conference is scheduled for next month. More details to follow.\n\nCongratulations!\nAlice' }, }, }, ]; // --------------------------------------------------------------------------- // Identities // --------------------------------------------------------------------------- const IDENTITIES = [ { id: 'identity-001', name: 'Dev User', email: 'dev@localhost', replyTo: null, bcc: null, textSignature: '-- \nDev User\nJMAP Webmail Developer', htmlSignature: '

--
Dev User
JMAP Webmail Developer

', mayDelete: false, }, ]; // --------------------------------------------------------------------------- // Threads // --------------------------------------------------------------------------- function buildThreads() { const map = new Map(); for (const e of emails) { const ids = map.get(e.threadId) || []; ids.push(e.id); map.set(e.threadId, ids); } return Array.from(map.entries()).map(([id, emailIds]) => ({ id, emailIds })); } // --------------------------------------------------------------------------- // JMAP method handlers // --------------------------------------------------------------------------- type MethodArgs = Record; type MethodResult = [string, Record, string]; function handleCoreEcho(args: MethodArgs, callId: string): MethodResult { return ['Core/echo', args, callId]; } function handleMailboxGet(_args: MethodArgs, callId: string): MethodResult { recomputeMailboxCounts(); return ['Mailbox/get', { accountId: ACCOUNT_ID, state: nextState(), list: mailboxes, notFound: [] }, callId]; } function handleMailboxSet(args: MethodArgs, callId: string): MethodResult { const created: Record = {}; const updated: Record = {}; const destroyed: string[] = []; const create = args.create as Record> | undefined; if (create) { for (const [key, data] of Object.entries(create)) { const newId = `mb-${Date.now()}-${key}`; mailboxes.push({ id: newId, name: (data.name as string) || 'New Folder', role: null, sortOrder: mailboxes.length + 1, totalEmails: 0, unreadEmails: 0, }); created[key] = { id: newId }; } } const update = args.update as Record> | undefined; if (update) { for (const [id, changes] of Object.entries(update)) { const mb = mailboxes.find((m) => m.id === id); if (mb) { if (changes.name !== undefined) mb.name = changes.name as string; if (changes.sortOrder !== undefined) mb.sortOrder = changes.sortOrder as number; updated[id] = null; } } } const destroy = args.destroy as string[] | undefined; if (destroy) { for (const id of destroy) { const idx = mailboxes.findIndex((m) => m.id === id); if (idx !== -1) { mailboxes.splice(idx, 1); // Move emails from deleted mailbox to trash const trash = mailboxes.find((m) => m.role === 'trash'); for (const e of emails) { if (e.mailboxIds[id]) { delete e.mailboxIds[id]; if (trash) e.mailboxIds[trash.id] = true; } } destroyed.push(id); } } } recomputeMailboxCounts(); return ['Mailbox/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; } function handleEmailQuery(args: MethodArgs, callId: string): MethodResult { const filter = args.filter as Record | undefined; const limit = (args.limit as number) || 50; const position = (args.position as number) || 0; let filtered = [...emails]; if (filter?.inMailbox) { filtered = filtered.filter((e) => e.mailboxIds[filter.inMailbox]); } if (filter?.text) { const q = (filter.text as string).toLowerCase(); filtered = filtered.filter( (e) => (e.subject?.toLowerCase().includes(q)) || (e.preview?.toLowerCase().includes(q)) || e.from?.some((f) => f.name?.toLowerCase().includes(q) || f.email.toLowerCase().includes(q)), ); } // Sort newest first filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); const total = filtered.length; const ids = filtered.slice(position, position + limit).map((e) => e.id); return ['Email/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids, total, position, canCalculateChanges: false }, callId]; } function handleEmailGet(args: MethodArgs, callId: string): MethodResult { let ids = args.ids as string[] | undefined; const properties = args.properties as string[] | undefined; // Handle back-references (#ids) if (!ids && args['#ids']) { // Will be resolved by the caller ids = args['#ids'] as string[]; } const list = ids ? emails.filter((e) => ids!.includes(e.id)) : emails; // If specific properties requested, filter them let result: unknown[] = list; if (properties) { result = list.map((e) => { const filtered: Record = { id: e.id }; for (const prop of properties) { if (prop in e) { filtered[prop] = (e as unknown as Record)[prop]; } } return filtered; }); } return ['Email/get', { accountId: ACCOUNT_ID, state: nextState(), list: result, notFound: [] }, callId]; } function handleEmailSet(args: MethodArgs, callId: string): MethodResult { const updated: Record = {}; const created: Record = {}; const destroyed: string[] = []; // --- Handle updates (move, keywords, etc.) --- const update = args.update as Record> | undefined; if (update) { for (const [id, changes] of Object.entries(update)) { const email = emails.find((e) => e.id === id); if (!email) continue; // Full mailboxIds replacement (move) if (changes.mailboxIds) { email.mailboxIds = changes.mailboxIds as Record; } // Full keywords replacement if (changes.keywords !== undefined) { email.keywords = changes.keywords as Record; } // Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc. for (const [key, value] of Object.entries(changes)) { if (key.startsWith('keywords/')) { const keyword = key.slice('keywords/'.length); if (value) { email.keywords[keyword] = true; } else { delete email.keywords[keyword]; } } } // Subject / other fields (for drafts) if (changes.subject !== undefined) email.subject = changes.subject as string; updated[id] = null; } } // --- Handle creates --- const create = args.create as Record> | undefined; if (create) { for (const [key, data] of Object.entries(create)) { const newId = `email-new-${Date.now()}-${key}`; // Extract preview text from bodyValues using textBody partId let previewText = ''; const textBodyArr = data.textBody as { partId: string }[] | undefined; const bodyVals = data.bodyValues as Record | undefined; if (Array.isArray(textBodyArr) && textBodyArr[0]?.partId && bodyVals) { previewText = bodyVals[textBodyArr[0].partId]?.value || ''; } else if (typeof data.textBody === 'string') { previewText = data.textBody; } const newEmail: MockEmail = { id: newId, threadId: `thread-new-${Date.now()}-${key}`, mailboxIds: (data.mailboxIds as Record) || { 'mb-drafts': true }, keywords: (data.keywords as Record) || {}, size: 1000, receivedAt: new Date().toISOString(), from: (data.from as MockEmail['from']) || [{ name: 'Dev User', email: 'dev@localhost' }], to: (data.to as MockEmail['to']) || [], cc: (data.cc as MockEmail['cc']) || [], subject: (data.subject as string) || '(no subject)', preview: (previewText || (data.subject as string) || '').slice(0, 120), hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, }; emails.unshift(newEmail); created[key] = { id: newId }; } } // --- Handle destroys (permanent delete) --- const destroy = args.destroy as string[] | undefined; if (destroy) { for (const id of destroy) { const idx = emails.findIndex((e) => e.id === id); if (idx !== -1) { emails.splice(idx, 1); destroyed.push(id); } } } recomputeMailboxCounts(); return ['Email/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; } function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult { return ['Identity/get', { accountId: ACCOUNT_ID, state: nextState(), list: IDENTITIES, notFound: [] }, callId]; } function handleIdentitySet(args: MethodArgs, callId: string): MethodResult { const created: Record = {}; const create = args.create as Record | undefined; if (create) { for (const key of Object.keys(create)) { created[key] = { id: `identity-new-${Date.now()}-${key}` }; } } return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId]; } function handleThreadGet(args: MethodArgs, callId: string): MethodResult { const ids = args.ids as string[] | undefined; const threads = buildThreads(); const list = ids ? threads.filter((t) => ids.includes(t.id)) : threads; return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; } function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult { return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId]; } function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; } function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult { return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId]; } function handleContactCardGet(_args: MethodArgs, callId: string): MethodResult { return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), notFound: [], list: [ { id: 'contact-001', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] }, emails: { e1: { address: 'alice@example.com' } } }, { id: 'contact-002', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Smith' }] }, emails: { e1: { address: 'bob@example.org' } }, phones: { p1: { number: '+1-555-0123' } } }, { id: 'contact-003', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Carol' }, { kind: 'surname', value: 'Davis' }] }, emails: { e1: { address: 'carol@example.com' } } }, ], }, callId]; } function handleAddressBookGet(_args: MethodArgs, callId: string): MethodResult { return ['AddressBook/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'ab-1', name: 'Personal', isDefault: true }], notFound: [] }, callId]; } function handleCalendarGet(_args: MethodArgs, callId: string): MethodResult { return ['Calendar/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'cal-1', name: 'Personal', color: '#4285f4', isVisible: true, isDefault: true }], notFound: [] }, callId]; } function handleCalendarEventGet(_args: MethodArgs, callId: string): MethodResult { return ['CalendarEvent/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; } function handleCalendarEventQuery(_args: MethodArgs, callId: string): MethodResult { return ['CalendarEvent/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: [], total: 0, position: 0, canCalculateChanges: false }, callId]; } function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult { return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; } // Catch-all for unknown methods function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult { return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId]; } const METHOD_HANDLERS: Record MethodResult> = { 'Core/echo': handleCoreEcho, 'Mailbox/get': handleMailboxGet, 'Mailbox/set': handleMailboxSet, 'Email/query': handleEmailQuery, 'Email/get': handleEmailGet, 'Email/set': handleEmailSet, 'Email/changes': (_args, callId) => ['Email/changes', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), hasMoreChanges: false, created: [], updated: [], destroyed: [] }, callId], 'Thread/get': handleThreadGet, 'Identity/get': handleIdentityGet, 'Identity/set': handleIdentitySet, 'EmailSubmission/set': handleEmailSubmissionSet, 'Quota/get': handleQuotaGet, 'VacationResponse/get': handleVacationResponseGet, 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], 'ContactCard/get': handleContactCardGet, 'ContactCard/set': (_args, callId) => ['ContactCard/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], 'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: ['contact-001', 'contact-002', 'contact-003'], total: 3, position: 0 }, callId], 'AddressBook/get': handleAddressBookGet, 'Calendar/get': handleCalendarGet, 'CalendarEvent/get': handleCalendarEventGet, 'CalendarEvent/query': handleCalendarEventQuery, 'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], 'SieveScript/get': handleSieveScriptGet, 'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], }; // --------------------------------------------------------------------------- // Resolve back-references between method calls // --------------------------------------------------------------------------- function resolveBackReferences( methodCalls: Array<[string, MethodArgs, string]>, responses: MethodResult[], ): Array<[string, MethodArgs, string]> { return methodCalls.map((call) => { const [method, args, callId] = call; const resolved = { ...args }; // Handle #ids back-reference (used by Email/get after Email/query) if (resolved['#ids']) { const ref = resolved['#ids'] as { resultOf: string; name: string; path: string }; const refResponse = responses.find((r) => r[2] === ref.resultOf && r[0] === ref.name); if (refResponse) { const path = ref.path.replace(/^\//, ''); resolved.ids = refResponse[1][path] as string[]; } delete resolved['#ids']; } return [method, resolved, callId] as [string, MethodArgs, string]; }); } // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- function isDevMockEnabled(): boolean { return process.env.DEV_MOCK_JMAP === 'true'; } function getBaseUrl(request: NextRequest): string { const proto = request.headers.get('x-forwarded-proto') || 'http'; const host = request.headers.get('host') || 'localhost:3000'; return `${proto}://${host}`; } export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { if (!isDevMockEnabled()) { return NextResponse.json({ error: 'Mock JMAP server is disabled' }, { status: 404 }); } const { path } = await params; const joined = path.join('/'); // Session endpoint: /.well-known/jmap if (joined === '.well-known/jmap') { const base = getBaseUrl(request); return NextResponse.json({ capabilities: { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ['i;ascii-casemap', 'i;ascii-numeric', 'i;unicode-casemap'], }, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:submission': {}, 'urn:ietf:params:jmap:quota': {}, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:sieve': {}, }, accounts: { [ACCOUNT_ID]: { name: 'Dev User', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:submission': {}, 'urn:ietf:params:jmap:quota': {}, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:sieve': {}, }, }, }, primaryAccounts: { 'urn:ietf:params:jmap:mail': ACCOUNT_ID, 'urn:ietf:params:jmap:submission': ACCOUNT_ID, 'urn:ietf:params:jmap:quota': ACCOUNT_ID, 'urn:ietf:params:jmap:vacationresponse': ACCOUNT_ID, 'urn:ietf:params:jmap:contacts': ACCOUNT_ID, 'urn:ietf:params:jmap:calendars': ACCOUNT_ID, 'urn:ietf:params:jmap:sieve': ACCOUNT_ID, }, username: 'dev@localhost', apiUrl: `${base}/api/dev-jmap/api`, downloadUrl: `${base}/api/dev-jmap/download/{accountId}/{blobId}/{name}?accept={type}`, uploadUrl: `${base}/api/dev-jmap/upload/{accountId}/`, eventSourceUrl: `${base}/api/dev-jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}`, state: 'mock-session-state-1', }); } // Download endpoint: /download/{accountId}/{blobId}/{name} if (joined.startsWith('download/')) { const segments = joined.split('/'); // segments: ['download', accountId, blobId, name] const blobId = segments[2] || 'unknown'; const name = decodeURIComponent(segments[3] || 'attachment'); const accept = new URL(request.url).searchParams.get('accept') || 'application/octet-stream'; // Find matching attachment across all emails let attachmentData: { name: string; type: string; size: number } | undefined; for (const email of emails) { const att = email.attachments?.find(a => a.blobId === blobId); if (att) { attachmentData = att; break; } } // Generate placeholder content for the blob const contentType = attachmentData?.type || accept; const fileName = attachmentData?.name || name; const body = `[Mock file content for blob ${blobId}: ${fileName}]`; return new NextResponse(body, { status: 200, headers: { 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${fileName}"`, }, }); } // EventSource endpoint: /eventsource if (joined === 'eventsource') { const ping = parseInt(new URL(request.url).searchParams.get('ping') || '0', 10); const pingInterval = ping > 0 ? ping : 30; const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); // Send initial state event const stateEvent = JSON.stringify({ '@type': 'StateChange', changed: { [ACCOUNT_ID]: { 'Email': nextState(), 'Mailbox': nextState(), 'Thread': nextState(), }, }, }); controller.enqueue(encoder.encode(`event: state\ndata: ${stateEvent}\n\n`)); // Send periodic pings to keep the connection alive const interval = setInterval(() => { try { controller.enqueue(encoder.encode(`event: ping\ndata: ${JSON.stringify({ interval: pingInterval })}\n\n`)); } catch { clearInterval(interval); } }, pingInterval * 1000); // Close after 5 minutes to prevent indefinite connections in dev setTimeout(() => { clearInterval(interval); try { controller.close(); } catch { /* already closed */ } }, 5 * 60 * 1000); }, }); return new NextResponse(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); } return NextResponse.json({ error: 'Not found' }, { status: 404 }); } export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { if (!isDevMockEnabled()) { return NextResponse.json({ error: 'Mock JMAP server is disabled' }, { status: 404 }); } const { path } = await params; const joined = path.join('/'); // JMAP API endpoint if (joined === 'api') { try { const body = await request.json(); const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>; if (!methodCalls || !Array.isArray(methodCalls)) { return NextResponse.json({ error: 'Invalid request: missing methodCalls' }, { status: 400 }); } const responses: MethodResult[] = []; // Process method calls sequentially (to support back-references) const resolved = resolveBackReferences(methodCalls, responses); for (let i = 0; i < methodCalls.length; i++) { const [method, , callId] = methodCalls[i]; // Use resolved args if available, otherwise original const args = i < resolved.length ? resolved[i][1] : methodCalls[i][1]; const handler = METHOD_HANDLERS[method]; if (handler) { const result = handler(args, callId); responses.push(result); } else { responses.push(handleUnknown(method, args, callId)); } // Re-resolve remaining calls with new responses if (i < methodCalls.length - 1) { const remaining = methodCalls.slice(i + 1); const reResolved = resolveBackReferences(remaining, responses); for (let j = 0; j < reResolved.length; j++) { resolved[i + 1 + j] = reResolved[j]; } } } return NextResponse.json({ methodResponses: responses }); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } } // Upload endpoint (accept but return a fake blob) if (joined.startsWith('upload/')) { return NextResponse.json({ accountId: ACCOUNT_ID, blobId: `blob-upload-${Date.now()}`, type: request.headers.get('content-type') || 'application/octet-stream', size: Number(request.headers.get('content-length') || 0), }); } return NextResponse.json({ error: 'Not found' }, { status: 404 }); }