The README opens like a product page: the mark, a line on what it is, the links, the language switch and a picture of the real interface running on an invented mailbox, in dark and light and in both languages. scripts/screenshots holds that invented mailbox and the script that photographs it, so the pictures can be redone. Previews now decode any HTML entity through the browser rather than a short list, so a Portuguese or marketing preview no longer prints ç or ·. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
144 lines
11 KiB
JavaScript
144 lines
11 KiB
JavaScript
// 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 = `<html><head><style>
|
|
.wrap{max-width:560px;margin:0 auto;font-family:Georgia,serif;color:#1d1d1f}
|
|
.band{background:#1B2BD0;color:#fff;padding:26px 30px}
|
|
.band h1{margin:0;font:700 24px/1.2 Arial,sans-serif}
|
|
.body{padding:22px 30px;background:#fff}
|
|
.story{border-top:1px solid #e3e5ea;padding:14px 0}
|
|
.story b{font:700 16px Arial,sans-serif}
|
|
</style></head><body><div class="wrap">
|
|
<div class="band"><h1>Field Notes</h1><div style="opacity:.8;font:13px Arial">The weekly digest</div></div>
|
|
<div class="body">
|
|
<p>Three short reads for the week, picked by hand.</p>
|
|
<div class="story"><b>What a slow build taught us about caching</b><p style="margin:.3em 0 0">A look at why the fastest fix was deleting code.</p></div>
|
|
<div class="story"><b>Small mail servers, big promises</b><p style="margin:.3em 0 0">Running your own, a year in.</p></div>
|
|
<div class="story"><b>The case for boring dashboards</b><p style="margin:.3em 0 0">Fewer charts, better decisions.</p></div>
|
|
</div></div></body></html>`;
|
|
|
|
const flightHtml = `<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td bgcolor="#0b3d91" style="color:#fff;padding:18px 24px;font-size:18px"><b>Booking confirmed</b></td></tr>
|
|
<tr><td bgcolor="#f4f6fb" style="padding:20px 24px;color:#1d1d1f"><p style="margin:0 0 6px"><b>LIS → GRU</b> · Fri 14 Nov, 22:40</p><p style="margin:0;color:#555">Seat 14A · Reference <b>QX7M2P</b></p></td></tr></table>`;
|
|
|
|
const quote = (who, when, text) =>
|
|
`<div class="gmail_quote"><div class="gmail_attr">On ${when}, ${who} wrote:</div><blockquote class="gmail_quote">${text}</blockquote></div>`;
|
|
|
|
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 <helena@studio.example>", date: "2026-10-20T09:10:00Z", mid: "l1@x", html: "<p>Hi Mara,</p><p>Here is the checklist for Thursday. Could you take the copy review and the release notes? Marcos has the infra items.</p><p>Helena</p>", 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: "<p>On it. I will send the notes by Wednesday noon.</p>" }),
|
|
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Helena Vargas <helena@studio.example>", date: "2026-10-21T08:30:00Z", mid: "l3@x", html: "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>" }),
|
|
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-22T07:55:00Z", mid: "l4@x", seen: false,
|
|
html: `<p>Morning both. The staging run finished green, so I think we are fine to go ahead on Thursday as planned.</p><p>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.</p><p>Marcos</p>${quote("Helena Vargas <helena@studio.example>", "Wed, Oct 21, 2026 at 8:30 AM", "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>")}` }),
|
|
m({ folder: "INBOX", thread: T.invoice, subject: "Invoice 2041 for October", from: "Northwind Billing <billing@northwind.example>", date: "2026-10-22T06:20:00Z", mid: "i1@x", seen: false, attach: true, html: "<p>Hello Mara,</p><p>Your invoice for October is ready. The total is due on 5 November.</p><p>Northwind Billing</p>" }),
|
|
m({ folder: "INBOX", thread: T.digest, subject: "Your weekly digest", from: "Field Notes <hello@fieldnotes.example>", 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 <no-reply@atlasair.example>", date: "2026-10-21T13:12:00Z", mid: "f1@x", html: flightHtml }),
|
|
m({ folder: "INBOX", thread: T.review, subject: "Design review notes", from: "Priya Nair <priya@studio.example>", date: "2026-10-20T16:45:00Z", mid: "r1@x", attach: true, flagged: true, html: "<p>Notes from today's review are attached. The short version: keep the sidebar, simplify the toolbar, and let the reading pane breathe.</p><p>Priya</p>" }),
|
|
m({ folder: "INBOX", thread: T.contract, subject: "Contract draft v3", from: "Tomas Reyes <tomas@legal.example>", date: "2026-10-19T14:05:00Z", mid: "c1@x", html: "<p>Mara, v3 of the contract is in the shared folder with the changes we agreed on. Tell me if anything looks off before Friday.</p><p>Tomas</p>" }),
|
|
m({ folder: "INBOX", thread: T.standup, subject: "Standup moves to 10:15", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-18T09:00:00Z", mid: "s1@x", html: "<p>Heads up: from Monday the standup is at 10:15.</p>" }),
|
|
m({ folder: "INBOX", thread: T.welcome, subject: "Welcome to the team workspace", from: "Workspace <team@workspace.example>", date: "2026-10-15T08:00:00Z", mid: "w1@x", html: "<p>Welcome aboard. Your workspace is ready.</p>" }),
|
|
];
|
|
|
|
// 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*</)?.[1] || f).trim();
|
|
const addrOf = (f) => (f.match(/<([^>]+)>/)?.[1] || f).toLowerCase();
|
|
const plain = (h) => h.replace(/<style[\s\S]*?<\/style>/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));
|