// A stand-in for the auth and protocols services that serves a small, entirely // invented mailbox in the JSON shapes the webmail expects. It exists so the // interface can be run and photographed without a mail server or real mail: // // node scripts/screenshots/stub-server.mjs # listens on :4001 // cd services/webmail && AUTH_SERVICE_URL=http://127.0.0.1:4001 \ // PROTOCOLS_SERVICE_URL=http://127.0.0.1:4001 npx next dev -p 3100 // // Nothing here is real: the names, the addresses (all on reserved example // domains) and the messages are made up. import http from "node:http"; const ME = "mara@lambdamail.example"; const prefs = { signature: "", auto_save_drafts: true, conversation_view: true }; const trusted = new Set(["helena@studio.example"]); const T = { launch: "10000000-0000-4000-8000-000000000001", invoice: "10000000-0000-4000-8000-000000000002", digest: "10000000-0000-4000-8000-000000000003", flight: "10000000-0000-4000-8000-000000000004", review: "10000000-0000-4000-8000-000000000005", contract: "10000000-0000-4000-8000-000000000006", welcome: "10000000-0000-4000-8000-000000000007", standup: "10000000-0000-4000-8000-000000000008", }; const digestHtml = `

Field Notes

The weekly digest

Three short reads for the week, picked by hand.

What a slow build taught us about caching

A look at why the fastest fix was deleting code.

Small mail servers, big promises

Running your own, a year in.

The case for boring dashboards

Fewer charts, better decisions.

`; const flightHtml = `
Booking confirmed

LIS → GRU · Fri 14 Nov, 22:40

Seat 14A · Reference QX7M2P

`; const quote = (who, when, text) => `
On ${when}, ${who} wrote:
${text}
`; let uidSeq = 100; const m = (o) => ({ uid: ++uidSeq, seen: true, answered: false, flagged: false, attach: false, ...o }); const msgs = [ m({ folder: "INBOX", thread: T.launch, subject: "Launch checklist", from: "Helena Vargas ", date: "2026-10-20T09:10:00Z", mid: "l1@x", html: "

Hi Mara,

Here is the checklist for Thursday. Could you take the copy review and the release notes? Marcos has the infra items.

Helena

", answered: true }), m({ folder: "Sent", thread: T.launch, subject: "Re: Launch checklist", from: `Mara Lima <${ME}>`, date: "2026-10-20T11:42:00Z", mid: "l2@x", own: true, html: "

On it. I will send the notes by Wednesday noon.

" }), m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Helena Vargas ", date: "2026-10-21T08:30:00Z", mid: "l3@x", html: "

Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.

Helena

" }), m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Marcos Oliveira ", date: "2026-10-22T07:55:00Z", mid: "l4@x", seen: false, html: `

Morning both. The staging run finished green, so I think we are fine to go ahead on Thursday as planned.

I did notice the backup job takes a little longer than it used to (about 11 minutes now). Not a blocker, but I will keep an eye on it and report back after the launch.

Marcos

${quote("Helena Vargas <helena@studio.example>", "Wed, Oct 21, 2026 at 8:30 AM", "

Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.

Helena

")}` }), m({ folder: "INBOX", thread: T.invoice, subject: "Invoice 2041 for October", from: "Northwind Billing ", date: "2026-10-22T06:20:00Z", mid: "i1@x", seen: false, attach: true, html: "

Hello Mara,

Your invoice for October is ready. The total is due on 5 November.

Northwind Billing

" }), m({ folder: "INBOX", thread: T.digest, subject: "Your weekly digest", from: "Field Notes ", date: "2026-10-21T18:00:00Z", mid: "d1@x", html: digestHtml }), m({ folder: "INBOX", thread: T.flight, subject: "Your booking LIS to GRU is confirmed", from: "Atlas Air ", date: "2026-10-21T13:12:00Z", mid: "f1@x", html: flightHtml }), m({ folder: "INBOX", thread: T.review, subject: "Design review notes", from: "Priya Nair ", date: "2026-10-20T16:45:00Z", mid: "r1@x", attach: true, flagged: true, html: "

Notes from today's review are attached. The short version: keep the sidebar, simplify the toolbar, and let the reading pane breathe.

Priya

" }), m({ folder: "INBOX", thread: T.contract, subject: "Contract draft v3", from: "Tomas Reyes ", date: "2026-10-19T14:05:00Z", mid: "c1@x", html: "

Mara, v3 of the contract is in the shared folder with the changes we agreed on. Tell me if anything looks off before Friday.

Tomas

" }), m({ folder: "INBOX", thread: T.standup, subject: "Standup moves to 10:15", from: "Marcos Oliveira ", date: "2026-10-18T09:00:00Z", mid: "s1@x", html: "

Heads up: from Monday the standup is at 10:15.

" }), m({ folder: "INBOX", thread: T.welcome, subject: "Welcome to the team workspace", from: "Workspace ", date: "2026-10-15T08:00:00Z", mid: "w1@x", html: "

Welcome aboard. Your workspace is ready.

" }), ]; // Opening a message marks it read, so a run can put the mailbox back as it began. const pristine = structuredClone(msgs); const reset = () => { msgs.splice(0, msgs.length, ...structuredClone(pristine)); }; const key = (x) => `${x.folder.toLowerCase()}:${x.uid}`; const nameOf = (f) => (f.match(/^"?([^"<]*?)"?\s* (f.match(/<([^>]+)>/)?.[1] || f).toLowerCase(); const plain = (h) => h.replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); const thread = (id) => msgs.filter((x) => x.thread === id).sort((a, b) => a.date.localeCompare(b.date)); function summary(id, folder) { const all = thread(id); const here = all.filter((x) => x.folder.toLowerCase() === folder.toLowerCase()); if (!here.length) return null; const latest = here[here.length - 1]; const incoming = all.filter((x) => !x.own); const last = incoming[incoming.length - 1]; const answered = last ? last.answered || all.slice(all.indexOf(last) + 1).some((x) => x.own) : false; const seen = new Set(); const participants = []; for (const x of all) { const a = addrOf(x.from); if (seen.has(a) || participants.length === 4) continue; seen.add(a); participants.push({ name: nameOf(x.from), address: a, me: Boolean(x.own) }); } return { thread_id: id, uid: latest.uid, subject: latest.subject, sender_address: addrOf(latest.from), from_display_name: nameOf(latest.from), snippet: plain(latest.html).slice(0, 140), received_at: latest.date, has_attachments: all.some((x) => x.attach), message_count: all.length, unseen_count: all.filter((x) => !x.seen).length, seen: all.every((x) => x.seen), flagged: all.some((x) => x.flagged), answered, participants, }; } const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; const readBody = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b ? JSON.parse(b) : {})); }); http.createServer(async (req, res) => { const url = new URL(req.url, "http://x"); const p = url.pathname; const q = url.searchParams; const body = ["POST", "PUT", "DELETE"].includes(req.method) ? await readBody(req) : {}; if (p === "/__reset") { reset(); return json(res, 200, { ok: true }); } if (p === "/api/v1/user/me" || p === "/api/v1/admin/me") return json(res, 200, { id: "u1", email: ME, role: "USER", locale: "en", mfa_enrolled: true, recovery_codes_left: 8 }); if (p === "/api/v1/user/preferences") { if (req.method === "POST") Object.assign(prefs, body); return json(res, 200, prefs); } if (p === "/api/v1/user/trusted-senders") { if (req.method === "PUT") trusted.add(body.address); if (req.method === "DELETE") trusted.delete(q.get("address")); return json(res, 200, { senders: [...trusted] }); } if (p === "/api/v1/auth/accounts") return json(res, 200, { accounts: [{ id: "u1", email: ME, active: true }] }); if (p === "/api/v1/mail/folders") { const count = (f) => msgs.filter((x) => x.folder === f).length; const unread = (f) => msgs.filter((x) => x.folder === f && !x.seen).length; return json(res, 200, [["INBOX", "inbox"], ["Sent", "sent"], ["Drafts", "drafts"], ["Archive", "archive"], ["Junk", "junk"], ["Trash", "trash"]] .map(([name, use], i) => ({ id: `f${i}`, name, special_use: use, unread_count: unread(name), total_count: count(name) }))); } if (p === "/api/v1/mail/threads") { const f = q.get("folder") || "inbox"; const ids = [...new Set(msgs.filter((x) => x.folder.toLowerCase() === f.toLowerCase()).map((x) => x.thread))]; return json(res, 200, ids.map((id) => summary(id, f)).filter(Boolean).sort((a, b) => b.received_at.localeCompare(a.received_at))); } if (p.startsWith("/api/v1/mail/thread/")) { const all = thread(p.split("/").pop()); if (!all.length) return json(res, 404, { error: "NOT_FOUND" }); return json(res, 200, all.map((x, i) => ({ uid: x.uid, folder: x.folder, subject: x.subject, sender_address: addrOf(x.from), from_display_name: nameOf(x.from), snippet: plain(x.html).slice(0, 100), received_at: x.date, has_attachments: x.attach, seen: x.seen, flagged: x.flagged, own: Boolean(x.own), answered: !x.own && (x.answered || all.slice(i + 1).some((y) => y.own)), }))); } if (p.startsWith("/api/v1/mail/message/")) { const uid = Number(p.split("/").pop()); const x = msgs.find((y) => y.uid === uid && y.folder.toLowerCase() === (q.get("folder") || "").toLowerCase()); if (!x) return json(res, 404, {}); x.seen = true; return json(res, 200, { uid, subject: x.subject, from: x.from, to: x.own ? ["helena@studio.example"] : [ME], cc: [], date: x.date, text: plain(x.html), html: x.html, attachments: x.attach ? ["document.pdf"] : [], message_id: `<${x.mid}>`, in_reply_to: "", important: false, inline_images: {} }); } if (p === "/api/v1/mail/events") return json(res, 404, {}); if (p.startsWith("/api/v1/mail/")) return json(res, 200, { ok: true, uid: 1 }); return json(res, 404, { error: "NOT_FOUND", path: p }); }).listen(Number(process.env.PORT ?? 4001), () => console.log("demo mailbox on", process.env.PORT ?? 4001));