diff --git a/spec/mail-address-spec.mjs b/spec/mail-address-spec.mjs new file mode 100644 index 0000000000..38cd90fb5e --- /dev/null +++ b/spec/mail-address-spec.mjs @@ -0,0 +1,79 @@ +// Run: node --experimental-vm-modules spec/mail-address-spec.mjs +// Production recipient resolution and send routing, without network or mail. +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; + +const context = vm.createContext({ console }); +async function load(path, mocks) { + const module = new vm.SourceTextModule(await readFile(new URL(path, import.meta.url), "utf8"), { context }); + await module.link(async (name) => { + assert.ok(mocks[name], `missing mock ${name}`); + const exports = mocks[name]; + const mock = new vm.SyntheticModule(Object.keys(exports), function () { + for (const [key, value] of Object.entries(exports)) this.setExport(key, value); + }, { context }); + await mock.link(() => {}); + await mock.evaluate(); + return mock; + }); + await module.evaluate(); + return module.namespace; +} +const handles = new Map([["q13R608tx", "reader"], ["prutti", "other"], ["same", "lower"], ["SAME", "upper"], ["a.b", "literal"]]); +const database = { disconnect: async () => {}, db: { collection: (name) => { + if (name === "users") return { findOne: async ({ code }) => code === "ac25abcde" ? { _id: "permanent" } : null }; + if (name === "tells") return { createIndex: async () => {} }; + assert.equal(name, "@handles"); + return { find: ({ handle }, options) => { + assert.ok(options.maxTimeMS <= 2000); + const regex = new RegExp(handle.$regex, handle.$options); + return { limit: (count) => ({ toArray: async () => [...handles.keys()].filter((h) => regex.test(h)).slice(0, count).map((handle) => ({ handle })) }) }; + } }; +} } }; +const backend = await load("../system/backend/mail.mjs", { + "./mail-events.mjs": { observeMail: () => {} }, + "./authorization.mjs": { handleFor: () => {}, userIDFromHandleOrEmail: async (h) => handles.get(h) }, + "./filter.mjs": { filter: (s) => s }, + "../../shared/push.mjs": { sendToUser: () => {} }, + "../../shared/mail-privacy.mjs": { letterNotification: () => {}, mailErrorCode: () => {}, quietMailPush: () => {} }, + "./mail-media.mjs": { resolveMailMedia: () => {}, outsideMediaBody: () => {} }, +}); +for (const address of ["@q13R608tx", " @Q13R608TX ", "q13r608tx", "Q13R608TX@AESTHETIC.COMPUTER", "q13r608tx@mail.aesthetic.computer"]) { + assert.equal(await backend.subFromAddress(address, database), "reader", address); +} +for (const address of ["ac25abcde", "@AC25ABCDE", "AC25ABCDE@aesthetic.computer"]) { + assert.equal(await backend.subFromAddress(address, database), "permanent", address); +} +assert.equal(await backend.subFromAddress("@same", database), "lower", "exact case wins"); +assert.equal(await backend.subFromAddress("@SAME", database), "upper", "exact case wins"); +assert.equal(await backend.subFromAddress("@Same", database), undefined, "ambiguous case never chooses a recipient"); +assert.equal(await backend.subFromAddress("@A.B", database), "literal", "regex punctuation is literal"); +for (const address of ["@q13", "@tyttebear", "@.*", "@a+b", "outside@example.invalid", "@", "", null, {}]) { + assert.equal(await backend.subFromAddress(address, database), undefined, String(address)); +} + +const deliveries = [], outside = []; +const api = await load("../system/netlify/functions/mail.mjs", { + "../../backend/mail-events.mjs": { mailTrace: () => "test", recordMailEvent: () => {} }, + "../../backend/authorization.mjs": { authorize: async () => ({ sub: "sender" }) }, + "../../backend/database.mjs": { connect: async () => database }, + "../../backend/http.mjs": { respond: (statusCode, body, headers) => ({ statusCode, body, headers }) }, + "../../backend/mail-media.mjs": { attachmentList: () => {}, attachmentThumbnail: () => {}, resolveMailMedia: () => {} }, + "../../backend/mail.mjs": { ...backend, + deliver: async (options) => { deliveries.push(options); return { toHandle: "@q13R608tx" }; }, + sendOutside: async (options) => { outside.push(options); return {}; }, + }, + "../../../shared/mail-privacy.mjs": { mailErrorCode: () => "UNKNOWN" }, + mongodb: { ObjectId: class {} }, +}); +const send = (to) => api.handler({ httpMethod: "POST", headers: {}, body: JSON.stringify({ to, text: "Synthetic test letter" }) }); +assert.equal((await send("@Q13R608TX")).statusCode, 200); +assert.equal(deliveries[0].to, "reader"); +for (const to of ["@q13", "q13@aesthetic.computer", "q13@mail.aesthetic.computer", "@Same"]) { + assert.equal((await send(to)).statusCode, 404, to); +} +assert.equal(outside.length, 0, "unknown local recipients never fall through to SMTP"); +assert.equal((await send("outside@example.invalid")).statusCode, 200); +assert.equal(outside.length, 1); +console.log("mail address spec passed: full handles, case, ambiguity, permahandles and SMTP boundaries"); diff --git a/spec/mail-interaction-spec.mjs b/spec/mail-interaction-spec.mjs new file mode 100644 index 0000000000..001af5f53d --- /dev/null +++ b/spec/mail-interaction-spec.mjs @@ -0,0 +1,109 @@ +// Run: node spec/mail-interaction-spec.mjs +// Exercise the production piece with synthetic letters and pointer events. +import assert from "node:assert/strict"; +import { mailLinks, MailMedia } from "../system/public/aesthetic.computer/disks/common/mail-media.mjs"; + +let sequence = 0; +async function fixture({ width = 240, readStatus = 200 } = {}) { + const piece = await import(`../system/public/aesthetic.computer/disks/mail.mjs?test=${++sequence}`); + const letter = { id: "a".repeat(24), from: "@reader", subject: "A long letter", text: "First line. ".repeat(30) + "THE END", read: false, when: new Date().toISOString() }; + const mailbox = { status: 200, addresses: ["tester@aesthetic.computer"], unread: 2, + inbox: [letter, { ...letter, id: "b".repeat(24), text: "Second letter" }], + sent: [{ ...letter, to: "@reader" }] }; + const writes = [], requests = []; + let fields; + const noop = () => {}; + const painter = { box: noop, write: (text, pos) => { writes.push({ text, ...pos }); } }; + const api = { + user: {}, query: {}, colon: [], params: [], screen: { width, height: 320 }, + hud: { label: noop }, gizmo: { EllipsisTicker: class {} }, + store: { retrieve: async () => null }, + ui: { TextFields: class { + constructor() { fields = this; this.values = {}; this.input = {}; } + focus() {} paint() {} act() {} sync() {} + } }, + net: { userRequest: async (method, path, body) => { + requests.push({ method, path, body }); + if (method === "GET") return mailbox; + return { status: readStatus }; + } }, + needsPaint: noop, send: noop, wipe: noop, mask: noop, unmask: noop, + ink: () => painter, line: noop, + text: { box: (text, _pos, width) => ({ box: { height: Math.ceil(text.length / Math.max(1, Math.floor(width / 4))) * 9 } }) }, + }; + await piece.boot(api); + const paint = () => { writes.length = 0; piece.paint(api); }; + const event = (type, x, y, delta = { y: 0 }) => piece.act({ ...api, event: { x, y, delta, is: (value) => value === type } }); + const tap = async (x, y) => { event("touch", x, y); event("lift", x, y); await new Promise(setImmediate); paint(); }; + paint(); + return { piece, api, letter, mailbox, writes, requests, paint, event, tap, fields }; +} + +const f = await fixture(); +const body = f.writes.find((w) => w.text.startsWith("First line")); +assert.ok(body.text.endsWith("…"), "phone inbox starts with a preview"); +await f.tap(body.x + 5, body.y + 2); +assert.ok(f.writes.some((w) => w.text.endsWith("THE END")), "tapping the preview reveals the full letter"); +assert.ok(!f.writes.some((w) => w.text === "send"), "opening a letter does not compose a reply"); +assert.deepEqual(f.requests.filter((r) => r.body?.action === "read").map((r) => r.body), [{ action: "read", id: f.letter.id }]); +assert.equal(f.mailbox.unread, 1, "only the opened letter clears its unread badge"); +assert.equal(f.mailbox.inbox[1].read, false); + +// Opening and closing the same letter does not send another read request. +await f.tap(body.x + 5, body.y + 2); +assert.ok(f.writes.some((w) => w.text.startsWith("First line") && w.text.endsWith("…"))); +assert.equal(f.requests.filter((r) => r.body?.action === "read").length, 1); + +// The explicit reply control still addresses compose and carries the subject. +const reply = f.writes.find((w) => w.text === "reply"); +await f.tap(reply.x + 1, reply.y + 1); +assert.equal(f.fields.values.to, "@reader"); +assert.equal(f.fields.values.subject, "Re: A long letter"); +assert.ok(f.writes.some((w) => w.text === "send")); +f.piece.leave(); + +const drag = await fixture(); +const preview = drag.writes.find((w) => w.text.startsWith("First line")); +drag.event("touch", 20, preview.y); +drag.event("draw", 20, preview.y - 1, { y: -1 }); +drag.event("lift", 20, preview.y - 1); +drag.paint(); +assert.ok(!drag.writes.some((w) => w.text.endsWith("THE END")), "scrolling does not open a letter"); +assert.equal(drag.requests.filter((r) => r.body?.action === "read").length, 0); +drag.piece.leave(); + +const failed = await fixture({ readStatus: 500 }); +const failedBody = failed.writes.find((w) => w.text.startsWith("First line")); +await failed.tap(20, failedBody.y + 1); +assert.ok(failed.writes.some((w) => w.text.endsWith("THE END")), "read acknowledgement failure does not hide the letter"); +assert.equal(failed.mailbox.unread, 2, "failed acknowledgement leaves the badge alone"); +failed.piece.leave(); + +const sent = await fixture(); +const sentTab = sent.writes.find((w) => w.text === "sent"); +await sent.tap(sentTab.x + 1, sentTab.y + 1); +const sentBody = sent.writes.find((w) => w.text.startsWith("First line")); +await sent.tap(20, sentBody.y + 1); +assert.ok(sent.writes.some((w) => w.text.endsWith("THE END")), "sent letters also expand"); +assert.equal(sent.requests.filter((r) => r.body?.action === "read").length, 0, "opening sent mail never marks inbox mail read"); +sent.piece.leave(); + +assert.deepEqual(mailLinks("Listen (https://example.invalid/song). https://example.invalid/song javascript:alert(1)"), ["https://example.invalid/song"]); +assert.deepEqual(mailLinks("https://example.invalid/a_(b) https://user:password@example.invalid/private"), ["https://example.invalid/a_(b)"]); +const links = await fixture(); +links.letter.text = "Listen: https://example.invalid/music"; +const opened = []; +links.api.net.web = (...args) => opened.push(args); +links.paint(); +const link = links.writes.find((w) => w.text === "open https://example.invalid/music"); +assert.ok(link, "ordinary URLs have an explicit opening control"); +await links.tap(link.x + 2, link.y + 2); +assert.deepEqual(opened, [["https://example.invalid/music", true]]); +assert.equal(links.requests.filter((r) => r.body?.action === "read").length, 0, "a link tap does not also open its row"); +assert.ok(!links.writes.some((w) => w.text === "send"), "a link tap does not compose a reply"); +links.piece.leave(); + +const media = new MailMedia(); +const mediaItems = media.layout(links.api, { text: "https://aesthetic.computer/#abc", media: [{ label: "#abc", url: "https://aesthetic.computer/#abc", path: "painting#abc" }] }, 200, {}); +assert.equal(mediaItems.length, 1, "existing AC media cards do not get duplicate link controls"); +console.log("mail interaction spec passed: opening, reading, replies, scrolling, failures and sent mail"); diff --git a/system/backend/mail.mjs b/system/backend/mail.mjs index 29123f53ef..eb36d6ad11 100644 --- a/system/backend/mail.mjs +++ b/system/backend/mail.mjs @@ -31,18 +31,30 @@ const PERMAHANDLE = /^ac\d\d[a-z]{5}$/; // see lib/user-code.mjs // `@handle`, `ac25namuc`, `jeffrey@mail.aesthetic.computer`, or an email → sub. export async function subFromAddress(address, database) { - let to = (address || "").trim(); + let to = typeof address === "string" ? address.trim() : ""; if (!to) return undefined; for (const domain of INBOUND_DOMAINS) { if (to.toLowerCase().endsWith("@" + domain)) to = to.slice(0, -(domain.length + 1)); } - if (PERMAHANDLE.test(to)) { + if (to.startsWith("@")) to = to.slice(1); + if (!to) return undefined; + if (PERMAHANDLE.test(to.toLowerCase())) { const user = await database.db .collection("users") - .findOne({ code: to }, { projection: { _id: 1 } }); + .findOne({ code: to.toLowerCase() }, { projection: { _id: 1 } }); return user?._id; } - return userIDFromHandleOrEmail(to, database); + const exact = await userIDFromHandleOrEmail(to, database); + if (exact || to.includes("@")) return exact; + + // Preserve exact matches; forgive case only when one full handle matches. + // Short nicknames and ambiguous spellings must never pick a recipient. + const escaped = to.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const matches = await database.db.collection("@handles") + .find({ handle: { $regex: `^${escaped}$`, $options: "i" } }, { projection: { handle: 1 }, maxTimeMS: 2000 }) + .limit(2).toArray(); + if (matches.length !== 1) return undefined; + return userIDFromHandleOrEmail(matches[0].handle, database); } // How a message signs itself. Deliberately not `getHandleOrEmail` — that falls diff --git a/system/netlify/functions/mail.mjs b/system/netlify/functions/mail.mjs index 123cd24ce0..18aea52033 100644 --- a/system/netlify/functions/mail.mjs +++ b/system/netlify/functions/mail.mjs @@ -17,6 +17,7 @@ import { clean, deliver, mailbox, + INBOUND_DOMAINS, MAX_SUBJECT_LENGTH, sendOutside, subFromAddress, @@ -167,7 +168,8 @@ async function handleMail(event, context) { // Not a handle, and not an email anyone here signed up with: if it // is an address at all, the letter leaves the wall as real email. const toEmail = body.to.trim().toLowerCase(); - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(toEmail)) { + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(toEmail) || + INBOUND_DOMAINS.some((domain) => toEmail.endsWith("@" + domain))) { return respond(404, { message: "Recipient not found" }); } const sent = await sendOutside( diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs index e2f88bd1ed..9d569cfa1a 100644 --- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -260,12 +260,14 @@ const STRINGS = { composeHint: "enter går ned · tryk på en række", mediaHint: "#maleri · !bånd · $kidlisp", download: "hent", + openLink: "åbn", downloading: "henter fil…", downloadFailed: "filen kunne ikke hentes", noPreview: "ingen forhåndsvisning", whoTo: "til hvem?", nothingToSay: "intet at sige endnu", noOne: "ingen svarer på", + fullHandle: "brug hele @handlet fra chatten", couldntSend: "kunne ikke sendes", to: "til", re: "re", @@ -313,12 +315,14 @@ const STRINGS = { composeHint: "enter moves down · tap a row to jump", mediaHint: "#painting · !tape · $kidlisp", download: "download", + openLink: "open", downloading: "downloading…", downloadFailed: "couldn't download that file", noPreview: "no preview", whoTo: "who is it to?", nothingToSay: "nothing to say yet", noOne: "no one answers to", + fullHandle: "use the full @handle from chat", couldntSend: "couldn't send that", to: "to", re: "re", diff --git a/system/public/aesthetic.computer/disks/common/mail-media.mjs b/system/public/aesthetic.computer/disks/common/mail-media.mjs index f4a9b3a78a..f78aa153fb 100644 --- a/system/public/aesthetic.computer/disks/common/mail-media.mjs +++ b/system/public/aesthetic.computer/disks/common/mail-media.mjs @@ -1,5 +1,21 @@ // Mail-only previews stay in memory; private bytes never enter the public // bitmap loader, persistent browser store, or piece diagnostics. +export function mailLinks(text) { + const links = new Set(); + for (const match of (text || "").matchAll(/https?:\/\/[^\s<>"']+/gi)) { + let raw = match[0].replace(/[.,;]+$/, ""); + for (const [open, close] of [["(", ")"], ["[", "]"]]) { + while (raw.endsWith(close) && raw.split(close).length > raw.split(open).length) raw = raw.slice(0, -1); + } + try { + const url = new URL(raw); + if (["https:", "http:"].includes(url.protocol) && !url.username && !url.password) links.add(url.href); + } catch {} + if (links.size === 10) break; + } + return [...links]; +} + export class MailMedia { previews = new Map(); active = 0; @@ -15,6 +31,8 @@ export class MailMedia { ...(letter.media || []).map((ref) => ({ key: ref.label, label: ref.label, image: !!ref.preview, ref, })), + ...mailLinks(letter.text).filter((url) => !(letter.media || []).some((ref) => ref.url === url)) + .map((url) => ({ key: url, label: `${words.openLink} ${url}`, url })), ...(letter.attachments || []).map((file) => ({ key: `${letter.id}:${file.index}`, label: `${words.download} ${file.name} (${Math.max(1, Math.ceil(file.size / 1024))} KiB)`, image: file.image, file, id: letter.id, @@ -80,6 +98,7 @@ export class MailMedia { } async open(api, item) { + if (item.url) return api.net.web(item.url, true); if (item.ref) return api.jump(item.ref.path); const res = await api.net.userRequest("GET", `/api/mail?id=${item.id}&attachment=${item.file.index}&json=1`); if (res.status !== 200) throw new Error("Download unavailable"); diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs index 18e74ed3ec..b748d0c335 100644 --- a/system/public/aesthetic.computer/disks/mail.mjs +++ b/system/public/aesthetic.computer/disks/mail.mjs @@ -48,7 +48,9 @@ let status = "loading"; // loading, loaded, error, noauth let mail = null; let prefs = null; // blast subscription + history, fetched when prefs opens let errorMsg = null; -let rows = []; // [{ y0, y1, who, email, subject }] — paint measures them, act replies to them +let rows = []; // painted letter bounds and their explicit reply controls +let expandedId = null; +const reading = new Set(); let ellipsisTicker; let busy = false; let fields; // to · subject · body, sharing one keyboard (see lib/type.mjs) @@ -201,6 +203,9 @@ async function boot(api) { composeNote = null; replyTo = null; hits = []; + rows = []; + expandedId = null; + scroll = 0; installMailTestHook(api); mediaView.clear(); mediaNote = null; @@ -291,7 +296,7 @@ async function send(api, { to, subject, body }) { leaveCompose(api, "sent"); await refresh(api); } else if (res.status === 404) { - composeNote = `${s.noOne} ${to.trim()}`; + composeNote = `${s.noOne} ${to.trim()}. ${s.fullHandle}`; } else { composeNote = s.couldntSend; } @@ -327,7 +332,7 @@ function compose(api, to, subject) { // Answer a letter: compose to whoever sent it — outside letters go back out // as email — with the subject carried over under `Re:`. function answer(api, who, email, subject) { - const address = who?.startsWith("@") ? who : email; + const address = who?.startsWith("@") || /^ac\d\d[a-z]{5}$/.test(who || "") ? who : email; if (!address) return; const re = subject && !/^re:/i.test(subject) ? `Re: ${subject}` : subject || null; compose(api, address, re); @@ -339,6 +344,7 @@ function showView(api, value) { if (view === "compose") leaveCompose(api, value); else view = value; scroll = 0; + expandedId = null; if (value === "prefs" && !prefs) { api.net.userRequest("GET", "/api/mail-status").then((res) => { if (res.status === 200) prefs = res; @@ -350,6 +356,25 @@ function showView(api, value) { tell(); } +// A letter is read when opened, not merely when its preview is painted. +async function markLetterRead(api, letter) { + if (letter.read || reading.has(letter.id)) return; + const box = mail; + reading.add(letter.id); + try { + const res = await api.net.userRequest("POST", "/api/mail", { action: "read", id: letter.id }); + if (res.status === 200 && !letter.read) { + letter.read = true; + box.unread = Math.max(0, box.unread - 1); + } + } catch {} // The letter stays open; its unread state can be retried later. + finally { + reading.delete(letter.id); + api.needsPaint(); + tell(); + } +} + // The mark-all-read control. async function markAllRead(api) { if (busy) return; @@ -476,6 +501,7 @@ function paint(api) { const c = t.chat; const s = S(); hits = []; + rows = []; mediaHits = []; wipe(...t.bg); @@ -579,15 +605,18 @@ function paint(api) { const footer = frame.y + frame.height + 4; const sendBox = control(api, x, footer, s.send, { type: "send" }, { selected: true }); + const note = composeNote || s.composeHint; + const noteWidth = wide - sendBox.w - 6; ink(composeNote ? [255, 130, 130] : c.timestamp).write( - composeNote || s.composeHint, + note, { x: sendBox.x + sendBox.w + 6, y: footer + 2 }, undefined, - wide - sendBox.w - 6, - false, + noteWidth, + true, CHIP_FONT, ); - ink(c.timestamp).write(s.mediaHint, { x, y: footer + CHIP_H + 4 }, undefined, wide, true, CHIP_FONT); + const noteH = text.box(note, undefined, noteWidth, 1, true, CHIP_FONT).box.height; + ink(c.timestamp).write(s.mediaHint, { x, y: footer + Math.max(CHIP_H, noteH + 2) + 4 }, undefined, wide, true, CHIP_FONT); paintCorner(api); paintSettings(api); return; @@ -623,8 +652,9 @@ function paint(api) { rows = []; const measured = letters.map((letter) => { - let body = letter.text; - if (compact) { + const expanded = letter.id === expandedId; + let body = expanded && letter.subject ? `${letter.subject}\n${letter.text}` : letter.text; + if (compact && !expanded) { const most = Math.floor(bounds / cw) * 2 - 1; if (body.length > most) body = body.slice(0, most) + "…"; } @@ -652,7 +682,8 @@ function paint(api) { // Stripe the row behind everything, so a long message stays one block — // the tema's stripes, unread rows on the brighter one. ink(unread ? t.stripeB : i % 2 ? t.stripeA : [...t.stripeA, 110]).box(x, ly, wide, rowH); - rows.push({ y0: ly, y1: ly + rowH, who, email, subject: letter.subject }); + const row = { y0: ly, y1: ly + rowH, letter, who, email, reply: null }; + rows.push(row); if (unread) ink(c.log).box(x + 2, yy + 2, 3, 3); ink(unread ? c.handle : c.timestamp).write(who, { x: x + 8, y: yy }, undefined, undefined, false, face); @@ -671,19 +702,17 @@ function paint(api) { ink([...c.timestamp, 170]).write(s.outside, { x: afterWho, y: yy + smallTint }, undefined, undefined, false, CHIP_FONT); afterWho += s.outside.length * 4 + 6; } - // The reply chip sits at the row's right end, the clock beside it — - // the whole row answers the letter, the chip just says so. It wakes - // on the hot row; inbox only, since `sent` rows aren't answers. + // Only the reply chip composes; the rest of the row opens the letter. let right = screen.width - x; if (view === "inbox") { const chipW = chipWidth(s.reply); right -= chipW; - paintChip(api, right, yy - (compact ? 2 : 1), s.reply, { tint: WRITE, dim: !hot }); + row.reply = paintChip(api, right, yy - (compact ? 2 : 1), s.reply, { tint: WRITE, dim: !hot }); right -= 4; } const agoW = cw * 5 + 4; right -= agoW; - if (letter.subject) { + if (letter.subject && letter.id !== expandedId) { // Cut the subject to the room left before the timestamp — `write` // with a bound and no wrap still runs on under the clock. const room = Math.floor((right - afterWho) / cw); @@ -890,8 +919,8 @@ function act(api) { } } - // Tap a letter (or its reply chip — same thing) to answer it: the field - // opens already addressed. A drag that ended on a letter was a scroll. + // A tap opens the whole letter; the reply chip alone starts an answer. + // A drag that ended on a letter was a scroll. if (e.is("lift") && !dragged && listing) { const media = mediaHits.find((box) => hit(box)); if (media) { @@ -906,11 +935,13 @@ function act(api) { return; } const row = rows.find((r) => e.y >= r.y0 && e.y < r.y1); - if (row && e.y >= listTop) { - // A sent letter's row just writes to them again — not a reply. - const address = row.who?.startsWith("@") ? row.who : row.email; - if (view === "inbox") answer(api, row.who, row.email, row.subject); - else if (address) compose(api, address); + if (row && e.y >= listTop && e.x >= 6 && e.x < api.screen.width - 6) { + if (view === "inbox" && hit(row.reply)) { + answer(api, row.who, row.email, row.letter.subject); + } else { + expandedId = expandedId === row.letter.id ? null : row.letter.id; + if (expandedId && view === "inbox") markLetterRead(api, row.letter); + } needsPaint(); } } diff --git a/tests/browser/mail-opening.test.mjs b/tests/browser/mail-opening.test.mjs new file mode 100644 index 0000000000..9b4f3947ea --- /dev/null +++ b/tests/browser/mail-opening.test.mjs @@ -0,0 +1,143 @@ +// Run: node tests/browser/mail-opening.test.mjs +// One headless browser, local piece overrides and a synthetic mailbox at the +// piece API boundary. No auth session or production mailbox is used. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { ACSession } from "./ac-harness.mjs"; + +const root = new URL("../../", import.meta.url); +let source = await readFile(new URL("system/public/aesthetic.computer/disks/mail.mjs", root), "utf8"); +const theme = await readFile(new URL("system/public/aesthetic.computer/disks/common/laklok-tema.mjs", root), "utf8"); +const media = await readFile(new URL("system/public/aesthetic.computer/disks/common/mail-media.mjs", root), "utf8"); +const letter = { id: "a".repeat(24), from: "@reader", subject: "Opening a letter", + text: "This is a synthetic letter for the phone layout check. ".repeat(5) + "THE END", + when: new Date().toISOString(), read: false, attachments: [], media: [] }; +const mailbox = { addresses: ["tester@aesthetic.computer"], unread: 2, + inbox: [letter, { ...letter, id: "b".repeat(24), from: "@other", subject: "Another letter", text: "This letter should stay unread. https://example.invalid/music" }], sent: [] }; +// Keep production lifecycle functions intact behind a synthetic networking API. +for (const name of ["boot", "sim", "paint", "act"]) { + source = source.replace(`function ${name}(api) {`, `function ${name}Original(api) {`); +} +source += ` +const fixtureMailbox = ${JSON.stringify(mailbox)}; +async function fixtureRequest(method, path, body) { + if (path !== '/api/mail') return { status: 200 }; + if (method === 'GET') return { ...structuredClone(fixtureMailbox), status: 200 }; + if (body.action === 'read') { + testChannel.postMessage({ readRequest: body }); + const row = fixtureMailbox.inbox.find(r => r.id === body.id); + if (row && !row.read) { row.read = true; fixtureMailbox.unread--; } + return { status: 200, read: row ? 1 : 0 }; + } + return { status: 404, message: 'Recipient not found' }; +} +function fixtureApi(api) { + api.user = { sub: 'synthetic-mail-test' }; + api.net.userRequest = fixtureRequest; + return api; +} +function boot(api) { return bootOriginal(fixtureApi(api)); } +function sim(api) { return simOriginal(fixtureApi(api)); } +function act(api) { return actOriginal(fixtureApi(api)); } +function paint(api) { + paintOriginal(fixtureApi(api)); + testChannel?.postMessage({ layout: { width: api.screen.width, height: api.screen.height, + expandedId, rows: rows.map(r => ({ id: r.letter.id, y0: r.y0, y1: r.y1, reply: r.reply })), + links: mediaHits.filter(h => h.item.url).map(({ x, y, w, h }) => ({ x, y, w, h })) } }); +} +`; +const ac = await ACSession.open(); +const errors = []; +const glyphRequests = new Set(); +let lastGlyphRequest = 0; +for (const event of ["requestfinished", "requestfailed"]) ac.page.on(event, (req) => glyphRequests.delete(req)); +ac.page.on("console", (message) => { if (message.type() === "error" && errors.length < 10) errors.push(message.text()); }); +try { + await ac.page.setViewport({ width: 390, height: 780, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + await ac.page.setBypassServiceWorker(true); + await ac.page.evaluateOnNewDocument(() => { + window.acDEBUG = false; + window.mailOpenedLinks = []; + window.mailReads = []; + window.open = (url) => { window.mailOpenedLinks.push(url); return { closed: false }; }; + const channel = new BroadcastChannel("ac-mail-test"); + channel.onmessage = ({ data }) => { + if (data.layout) window.mailLayout = data.layout; + if (data.piece === "mail") window.mailState = data; + if (data.readRequest) window.mailReads.push(data.readRequest); + }; + }); + await ac.page.setRequestInterception(true); + ac.page.on("request", async (req) => { + const url = new URL(req.url()); + const json = (body, status = 200) => req.respond({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (url.pathname.endsWith("/disks/mail.mjs")) return req.respond({ status: 200, contentType: "text/javascript", body: source }); + if (url.pathname.endsWith("/disks/common/laklok-tema.mjs")) return req.respond({ status: 200, contentType: "text/javascript", body: theme }); + if (url.pathname.endsWith("/disks/common/mail-media.mjs")) return req.respond({ status: 200, contentType: "text/javascript", body: media }); + const glyphPath = url.pathname.replace(/^\/aesthetic\.computer\//, "/"); + if (glyphPath.startsWith("/disks/drawings/font_1/") && glyphPath.endsWith(".json")) { + glyphRequests.add(req); + lastGlyphRequest = Date.now(); + const body = await readFile(`${fileURLToPath(root)}system/public/aesthetic.computer${decodeURIComponent(glyphPath)}`, "utf8"); + await req.respond({ status: 200, contentType: "application/json", body }); + glyphRequests.delete(req); + return; + } + if (url.pathname === "/api/bdf-glyph" && req.method() === "GET") { + glyphRequests.add(req); + lastGlyphRequest = Date.now(); + return req.continue(); + } + if (url.pathname.startsWith("/api/") || req.method() !== "GET") return json({}); + return req.continue(); + }); + await ac.boot("mail?test=1"); + await ac.page.waitForFunction(() => window.mailState?.status === "loaded" && window.mailLayout?.rows.length, { timeout: 30000 }); + const tap = async (x, y) => { + const p = await ac.page.evaluate(({ x, y }) => { + const r = document.querySelector("#aesthetic-computer canvas").getBoundingClientRect(); + return { x: r.x + x / window.mailLayout.width * r.width, y: r.y + y / window.mailLayout.height * r.height }; + }, { x, y }); + await ac.page.touchscreen.tap(p.x, p.y); + }; + const shot = async (name) => { + const deadline = Date.now() + 15000; + while (glyphRequests.size || Date.now() - lastGlyphRequest < 500) { + assert.ok(Date.now() < deadline, "font requests should settle before the screenshot"); + await ac.wait(100); + } + await ac.wait(250); + return ac.shot(name); + }; + await shot("mail-opening/01-preview"); + let layout = await ac.page.evaluate(() => window.mailLayout); + assert.ok(layout.width < 320 || layout.height < 220, "exercise the compact layout"); + await tap(layout.links[0].x + 4, layout.links[0].y + 4); + await ac.page.waitForFunction(() => window.mailOpenedLinks.length === 1); + assert.deepEqual(await ac.page.evaluate(() => window.mailOpenedLinks), ["https://example.invalid/music"]); + assert.equal(await ac.page.evaluate(() => window.mailReads.length), 0, "link tap does not also select the row"); + await tap(25, layout.rows[0].y0 + 20); + await ac.page.waitForFunction(() => window.mailLayout?.expandedId && window.mailState?.unread === 1); + assert.deepEqual(await ac.page.evaluate(() => window.mailReads), [{ action: "read", id: letter.id }]); + assert.equal(await ac.page.evaluate(() => window.mailState.inbox[1].read), false); + assert.equal(await ac.page.evaluate(() => window.mailState.view), "inbox"); + await shot("mail-opening/02-open"); + layout = await ac.page.evaluate(() => window.mailLayout); + await tap(layout.rows[0].reply.x + 5, layout.rows[0].reply.y + 5); + await ac.page.waitForFunction(() => window.mailState?.view === "compose"); + assert.equal(await ac.page.evaluate(() => window.mailState.composeTo), "@reader"); + await shot("mail-opening/03-reply"); + await ac.page.evaluate(() => { + const c = new BroadcastChannel("ac-mail-test"); + c.postMessage({ type: "compose", to: "@q13", text: "This draft should stay here." }); + c.close(); + }); + await ac.page.waitForFunction(() => window.mailState?.composeNote?.includes("full @handle")); + await shot("mail-opening/04-recipient-error"); + console.log("mail opening browser test passed: links, full letters, individual read, reply and recipient feedback"); +} catch (error) { + await ac.shot("mail-opening/failure"); + console.error(JSON.stringify({ errors, pendingFonts: [...glyphRequests].map((req) => req.url()), state: await ac.page.evaluate(() => ({ status: window.mailState?.status, layout: window.mailLayout })) })); + throw error; +} finally { await ac.close(); }