diff --git a/system/backend/mail.mjs b/system/backend/mail.mjs new file mode 100644 index 0000000000..d5d803f462 --- /dev/null +++ b/system/backend/mail.mjs @@ -0,0 +1,114 @@ +// mail, 26.09.11 +// Internal AC mail. Nothing leaves the wall yet, so there is no SMTP in here — +// a message is a row in `tells`, the collection `tell` has been filling since +// 26.04 with nothing on the other end to read it. +// +// Addressing: a message is filed against the recipient's `sub`, never a handle, +// so a rename can't orphan a thread. Two spellings reach a person — their +// permahandle (`ac25namuc`, permanent) and their @handle (follows the rename). +// The permahandle is canonical; the @handle is an alias over it. + +import { handleFor, userIDFromHandleOrEmail } from "./authorization.mjs"; +import { filter } from "./filter.mjs"; +import { shell } from "./shell.mjs"; +import { sendToUser } from "../../shared/push.mjs"; + +export const MAIL_DOMAIN = "mail.aesthetic.computer"; // tier 2 binds this for real +export const MAX_TEXT_LENGTH = 500; + +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(); + if (!to) return undefined; + if (to.endsWith("@" + MAIL_DOMAIN)) to = to.slice(0, -(MAIL_DOMAIN.length + 1)); + if (PERMAHANDLE.test(to)) { + const user = await database.db + .collection("users") + .findOne({ code: to }, { projection: { _id: 1 } }); + return user?._id; + } + return userIDFromHandleOrEmail(to, database); +} + +// How a message signs itself. Deliberately not `getHandleOrEmail` — that falls +// back to the sender's real email, and an inbox is the wrong place to leak one. +export async function nameFor(sub, database) { + const handle = await handleFor(sub); + if (handle) return "@" + handle; + const user = await database.db + .collection("users") + .findOne({ _id: sub }, { projection: { code: 1 } }); + return user?.code; +} + +// Both spellings of a mailbox, for showing someone how to be reached. +export async function addressesFor(sub, database) { + const [handle, user] = await Promise.all([ + handleFor(sub), + database.db + .collection("users") + .findOne({ _id: sub }, { projection: { code: 1 } }), + ]); + const out = []; + if (user?.code) out.push(user.code + "@" + MAIL_DOMAIN); + if (handle) out.push(handle + "@" + MAIL_DOMAIN); + return out; +} + +export async function mailbox(database) { + const tells = database.db.collection("tells"); + await tells.createIndex({ to: 1, when: -1 }); + await tells.createIndex({ from: 1, when: -1 }); + return tells; +} + +export function clean(text) { + return filter((text || "").trim()).slice(0, MAX_TEXT_LENGTH); +} + +// Put one message in a mailbox and buzz whatever devices the reader carries. +export async function deliver({ from, to, text, device, verb = "mailed" }, database) { + const tells = await mailbox(database); + const [fromHandle, toHandle] = await Promise.all([ + nameFor(from, database), + nameFor(to, database), + ]); + const when = new Date(); + + const { insertedId } = await tells.insertOne({ + to, + toHandle, + from, + fromHandle, + text, + when, + read: false, + }); + + let push = { attempted: 0, succeeded: 0, failed: 0, pruned: 0 }; + try { + push = await sendToUser( + database.db, + to, + { + title: `${fromHandle} ${verb} you`, + body: text, + data: { + kind: "tell", + from: fromHandle || "", + tellId: insertedId.toString(), + piece: "mail", + }, + }, + { device }, + shell.log, + ); + } catch (err) { + // A silent phone shouldn't eat the letter — it's already in the mailbox. + shell.log("🔴 mail push failed:", err?.message || err); + } + + return { id: insertedId, fromHandle, toHandle, when, push }; +} diff --git a/system/netlify/functions/mail.mjs b/system/netlify/functions/mail.mjs new file mode 100644 index 0000000000..e5f5b4353a --- /dev/null +++ b/system/netlify/functions/mail.mjs @@ -0,0 +1,104 @@ +// mail, 26.09.11 +// The inbox half of AC mail. Sending already existed as `tell`; nothing ever +// read what it wrote, so this is the other end. +// +// GET /api/mail → inbox, sent, unread count, addresses +// POST /api/mail { to, text } → send +// POST /api/mail { action: "read" } → mark all read (or one, with `id`) +// headers: Authorization: Bearer + +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; +import { + addressesFor, + clean, + deliver, + mailbox, + subFromAddress, +} from "../../backend/mail.mjs"; +import { ObjectId } from "mongodb"; + +const PAGE = 50; + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(200, {}); + if (event.httpMethod !== "GET" && event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + const user = await authorize(event.headers); + if (!user?.sub) return respond(401, { message: "unauthorized" }); + + const database = await connect(); + try { + const tells = await mailbox(database); + + if (event.httpMethod === "GET") { + const [inbox, sent, unread, addresses] = await Promise.all([ + tells.find({ to: user.sub }).sort({ when: -1 }).limit(PAGE).toArray(), + tells.find({ from: user.sub }).sort({ when: -1 }).limit(PAGE).toArray(), + tells.countDocuments({ to: user.sub, read: { $ne: true } }), + addressesFor(user.sub, database), + ]); + + return respond(200, { + addresses, + unread, + inbox: inbox.map((m) => ({ + id: m._id, + from: m.fromHandle, + text: m.text, + when: m.when, + read: m.read === true, + })), + sent: sent.map((m) => ({ + id: m._id, + to: m.toHandle, + text: m.text, + when: m.when, + })), + }); + } + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + + if (body.action === "read") { + const where = { to: user.sub, read: { $ne: true } }; + if (body.id) where._id = new ObjectId(body.id); + const result = await tells.updateMany(where, { + $set: { read: true, readAt: new Date() }, + }); + return respond(200, { read: result.modifiedCount }); + } + + const text = clean(body.text); + if (!body.to) return respond(400, { message: "Missing recipient" }); + if (!text) return respond(400, { message: "Empty message" }); + + const to = await subFromAddress(body.to, database); + if (!to) return respond(404, { message: "Recipient not found" }); + + const sentMail = await deliver( + { from: user.sub, to, text, device: body.device }, + database, + ); + + return respond(200, { + status: "mailed", + to: sentMail.toHandle, + when: sentMail.when, + push: sentMail.push, + }); + } catch (err) { + console.error("🔴 mail error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/netlify/functions/tell.mjs b/system/netlify/functions/tell.mjs index a5d8474f5e..60866d1de6 100644 --- a/system/netlify/functions/tell.mjs +++ b/system/netlify/functions/tell.mjs @@ -1,25 +1,16 @@ -// tell, 26.04.23 → 26.06.11 (FCM → standard push migration) -// Send a one-way "tell" from one AC user to another. The recipient gets a -// push notification on every registered device — or just one, when `device` -// names a deviceId or label — and the message is stored in the `tells` -// collection as their inbox. +// tell, 26.04.23 → 26.09.11 (send path moved into `backend/mail.mjs`) +// Send a one-way "tell" from one AC user to another. The recipient gets a push +// notification on every registered device — or just one, when `device` names a +// deviceId or label — and the message lands in their mail. // // POST /api/tell -// body: { to: "@handle", text: "message", device?: "deviceId-or-label" } +// body: { to: "@handle" | "ac25namuc", text: "message", device?: "id-or-label" } // headers: Authorization: Bearer -import { - authorize, - userIDFromHandleOrEmail, - getHandleOrEmail, -} from "../../backend/authorization.mjs"; +import { authorize } from "../../backend/authorization.mjs"; import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; -import { filter } from "../../backend/filter.mjs"; -import { shell } from "../../backend/shell.mjs"; -import { sendToUser } from "../../../shared/push.mjs"; - -const MAX_TEXT_LENGTH = 500; +import { clean, deliver, subFromAddress } from "../../backend/mail.mjs"; export async function handler(event) { if (event.httpMethod !== "POST") { @@ -33,10 +24,8 @@ export async function handler(event) { return respond(400, { message: "Invalid JSON body" }); } - const rawTo = typeof body.to === "string" ? body.to.trim() : ""; - const rawText = typeof body.text === "string" ? body.text : ""; - if (!rawTo) return respond(400, { message: "Missing recipient" }); - const text = filter(rawText.trim()).slice(0, MAX_TEXT_LENGTH); + if (!body.to) return respond(400, { message: "Missing recipient" }); + const text = clean(body.text); if (!text) return respond(400, { message: "Empty message" }); const sender = await authorize(event.headers); @@ -44,61 +33,19 @@ export async function handler(event) { const database = await connect(); try { - const recipientSub = await userIDFromHandleOrEmail(rawTo, database); - if (!recipientSub) { - return respond(404, { message: "Recipient not found" }); - } - - const fromHandle = await getHandleOrEmail(sender.sub); - const toHandle = rawTo.startsWith("@") ? rawTo : `@${rawTo}`; - - const tells = database.db.collection("tells"); - await tells.createIndex({ to: 1, when: -1 }); - await tells.createIndex({ from: 1, when: -1 }); - - const when = new Date(); - const insertResult = await tells.insertOne({ - to: recipientSub, - toHandle, - from: sender.sub, - fromHandle, - text, - when, - }); + const to = await subFromAddress(body.to, database); + if (!to) return respond(404, { message: "Recipient not found" }); - // Push to the recipient's registered devices (all, or one if addressed). - let pushSummary = { attempted: 0, succeeded: 0, failed: 0, pruned: 0 }; - try { - pushSummary = await sendToUser( - database.db, - recipientSub, - { - title: `${fromHandle} told you`, - body: text, - data: { - kind: "tell", - from: fromHandle || "", - tellId: insertResult.insertedId.toString(), - piece: "chat", - }, - }, - { - device: - typeof body.device === "string" && body.device - ? body.device - : undefined, - }, - shell.log, - ); - } catch (err) { - shell.log("🔴 tell push send failed:", err?.message || err); - } + const told = await deliver( + { from: sender.sub, to, text, device: body.device, verb: "told" }, + database, + ); return respond(200, { status: "told", - to: toHandle, - when, - push: pushSummary, + to: told.toHandle, + when: told.when, + push: told.push, }); } catch (err) { console.error("🔴 tell error:", err); diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs index c4f4be9dfe..7fe2e2e94f 100644 --- a/system/public/aesthetic.computer/disks/mail.mjs +++ b/system/public/aesthetic.computer/disks/mail.mjs @@ -1,18 +1,20 @@ -// Mail, 2026.2.12 -// Email preferences and blast history for aesthetic.computer. +// Mail, 2026.2.12 → 2026.9.11 (inbox added) +// AC mail. `mail @handle words...` sends from the prompt; `mail` lands here. +// Tier 1: nothing leaves aesthetic.computer. See `system/backend/mail.mjs`. +let view = "inbox"; // inbox · sent · prefs let status = "loading"; // loading, loaded, error, noauth -let data = null; +let mail = null; +let prefs = null; // blast subscription + history, fetched when prefs opens let errorMsg = null; -let subBtn, unsubBtn; +let inboxBtn, sentBtn, prefsBtn, readBtn, subBtn, unsubBtn; let ellipsisTicker; -let toggling = false; +let busy = false; -// 📰 Meta function meta() { return { title: "Mail — aesthetic.computer", - desc: "Email preferences and blast history.", + desc: "Your aesthetic.computer inbox.", }; } @@ -21,15 +23,22 @@ async function boot({ user, gizmo, hud, net, ui, screen }) { hud.label("mail"); ellipsisTicker = new gizmo.EllipsisTicker(); + inboxBtn = new ui.TextButton("inbox", { screen }); + sentBtn = new ui.TextButton("sent", { screen }); + prefsBtn = new ui.TextButton("prefs", { screen }); + readBtn = new ui.TextButton("mark read", { screen }); + subBtn = new ui.TextButton("subscribe", { screen }); + unsubBtn = new ui.TextButton("unsubscribe", { screen }); + if (!user) { status = "noauth"; return; } try { - const res = await net.userRequest("GET", "/api/mail-status"); + const res = await net.userRequest("GET", "/api/mail"); if (res.status === 200) { - data = await res.json(); + mail = res; status = "loaded"; } else { status = "error"; @@ -39,9 +48,6 @@ async function boot({ user, gizmo, hud, net, ui, screen }) { status = "error"; errorMsg = err.message; } - - subBtn = new ui.TextButton("subscribe", { screen }); - unsubBtn = new ui.TextButton("unsubscribe", { screen }); } // 🧮 Sim @@ -49,22 +55,34 @@ function sim({ clock }) { ellipsisTicker?.update(clock.time()); } +// How long ago, short enough to sit next to a handle. +function ago(when) { + const secs = (Date.now() - new Date(when).getTime()) / 1000; + if (secs < 60) return "now"; + if (secs < 3600) return `${Math.floor(secs / 60)}m`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h`; + if (secs < 604800) return `${Math.floor(secs / 86400)}d`; + return new Date(when).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + // 🎨 Paint function paint(api) { - const { wipe, ink, screen, help } = api; + const { wipe, ink, screen, text, help } = api; wipe(24, 20, 28); if (status === "loading") { - ink(180) - .write( - "loading" + ellipsisTicker.text(help.repeat, { pad: false }), - { center: "xy", size: 1 }, - ); + ink(180).write( + "loading" + ellipsisTicker.text(help.repeat, { pad: false }), + { center: "xy", size: 1 }, + ); return; } if (status === "noauth") { - ink(180).write("log in to manage email preferences", { center: "xy" }); + ink(180).write("log in for mail", { center: "xy" }); return; } @@ -75,131 +93,195 @@ function paint(api) { return; } - const leftX = 6; + const x = 6; + const wide = screen.width - x * 2; let y = 8; - const lineH = 10; - // Header - ink(200, 180, 255).write("mail", { x: leftX, y, size: 2 }); + ink(200, 180, 255).write("mail", { x, y, size: 2 }); + if (mail.unread > 0) { + ink(0, 255, 255).write(`${mail.unread} new`, { x: x + 46, y: y + 4 }); + } + y += 20; + + // The two spellings of this mailbox — permahandle first, it never moves. + for (const address of mail.addresses) { + ink(address.startsWith("ac") ? 110 : 90).write(address, { x, y }); + y += 10; + } + y += 4; + + inboxBtn.reposition({ x, y, screen }); + ink(view === "inbox" ? [70, 60, 110] : [44, 40, 54]); + inboxBtn.paint(api); + sentBtn.reposition({ x: x + 48, y, screen }); + ink(view === "sent" ? [70, 60, 110] : [44, 40, 54]); + sentBtn.paint(api); + prefsBtn.reposition({ x: x + 88, y, screen }); + ink(view === "prefs" ? [70, 60, 110] : [44, 40, 54]); + prefsBtn.paint(api); y += 18; - // Subscription status - if (data.email) { - ink(120).write(data.email, { x: leftX, y }); - y += lineH + 2; + ink(60).box(x, y, wide, 1); + y += 8; - if (data.subscribed) { - ink(120, 200, 120).write("subscribed", { x: leftX, y }); - // Show unsubscribe button - unsubBtn.reposition({ x: leftX + 80, y: y - 2, screen }); + if (view === "prefs") { + paintPrefs(api, x, y, wide); + return; + } + + const letters = view === "inbox" ? mail.inbox : mail.sent; + + if (letters.length === 0) { + ink(100).write( + view === "inbox" ? "no mail yet" : "nothing sent yet", + { x, y }, + ); + if (view === "inbox") { + ink(70).write("try: mail @jeffrey hello", { x, y: y + 12 }); + } + return; + } + + if (view === "inbox" && mail.unread > 0) { + readBtn.reposition({ x, y, screen }); + ink(busy ? [60, 60, 60] : [40, 70, 70]); + readBtn.paint(api); + y += 18; + } + + for (const letter of letters) { + if (y > screen.height - 14) break; + const unread = view === "inbox" && !letter.read; + const who = (view === "inbox" ? letter.from : letter.to) || "someone"; + + if (unread) ink(0, 255, 255).box(x, y + 2, 3, 3); + ink(unread ? [170, 220, 255] : [130, 140, 170]).write(who, { + x: x + 6, + y, + }); + ink(70).write(ago(letter.when), { x: screen.width - 34, y }); + y += 11; + + const bounds = wide - 10; + ink(unread ? 245 : 190).write(letter.text, { x: x + 8, y }, undefined, bounds); + y += text.box(letter.text, { x: x + 8, y }, bounds).box.height + 6; + } +} + +function paintPrefs(api, x, y, wide) { + const { ink, screen } = api; + + if (!prefs) { + ink(140).write("loading prefs…", { x, y }); + return; + } + + if (prefs.email) { + ink(120).write(prefs.email, { x, y }); + y += 12; + + if (prefs.subscribed) { + ink(120, 200, 120).write("subscribed", { x, y }); + unsubBtn.reposition({ x: x + 80, y: y - 2, screen }); ink(120, 80, 80); unsubBtn.paint(api); } else { - ink(200, 120, 80).write("unsubscribed", { x: leftX, y }); - // Show subscribe button - subBtn.reposition({ x: leftX + 96, y: y - 2, screen }); + ink(200, 120, 80).write("unsubscribed", { x, y }); + subBtn.reposition({ x: x + 96, y: y - 2, screen }); ink(80, 160, 80); subBtn.paint(api); } - y += lineH + 4; + y += 16; } - if (toggling) { - ink(160).write("updating...", { x: leftX, y }); - y += lineH + 4; + if (busy) { + ink(160).write("updating…", { x, y }); + y += 14; } - // Divider - ink(60).box(leftX, y, screen.width - leftX * 2, 1); - y += 8; + ink(160, 140, 200).write("blast history", { x, y }); + y += 14; - // Blast history - ink(160, 140, 200).write("blast history", { x: leftX, y }); - y += lineH + 4; - - if (!data.blasts || data.blasts.length === 0) { - ink(100).write("no blasts sent yet", { x: leftX, y }); + if (!prefs.blasts || prefs.blasts.length === 0) { + ink(100).write("no blasts sent yet", { x, y }); return; } ink(80).write( - `${data.count} blast${data.count !== 1 ? "s" : ""} · ${data.totalSent} emails sent · ${data.totalUnsubscribed} unsub`, - { x: leftX, y }, + `${prefs.count} blast${prefs.count !== 1 ? "s" : ""} · ${prefs.totalSent} sent · ${prefs.totalUnsubscribed} unsub`, + { x, y }, ); - y += lineH + 4; + y += 14; - for (const blast of data.blasts) { + for (const blast of prefs.blasts) { if (y > screen.height - 16) break; - - const date = new Date(blast.when).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - - const statusColor = + const color = blast.status === "completed" ? [80, 180, 80] : blast.status === "in-progress" ? [200, 180, 80] : [180, 80, 80]; - - ink(...statusColor).write(blast.status, { x: leftX, y }); - ink(140).write(date, { x: leftX + 80, y }); - ink(100).write( - `${blast.sent}/${blast.totalAttempted} sent`, - { x: leftX + 150, y }, - ); - y += lineH; - - // Subject line - const subj = - blast.subject?.length > 50 - ? blast.subject.slice(0, 47) + "..." - : blast.subject || "(no subject)"; - ink(80).write(subj, { x: leftX + 8, y }); - y += lineH + 4; + ink(...color).write(blast.status, { x, y }); + ink(140).write(ago(blast.when), { x: x + 80, y }); + ink(100).write(`${blast.sent}/${blast.totalAttempted}`, { x: x + 130, y }); + y += 10; + ink(80).write(blast.subject || "(no subject)", { x: x + 8, y }, undefined, wide - 10); + y += 14; } } // 🎪 Act function act({ event: e, net, needsPaint }) { - if (status !== "loaded" || toggling) return; - - if (data.subscribed) { - unsubBtn?.act(e, async () => { - toggling = true; - needsPaint(); - try { - const res = await net.userRequest("POST", "/api/mail-status", { - action: "unsubscribe", - }); - if (res.status === 200) { - data.subscribed = false; - } - } catch (err) { - console.error("Unsubscribe failed:", err); - } - toggling = false; - needsPaint(); - }); + if (status !== "loaded") return; + + inboxBtn?.act(e, () => { + view = "inbox"; + needsPaint(); + }); + + sentBtn?.act(e, () => { + view = "sent"; + needsPaint(); + }); + + prefsBtn?.act(e, async () => { + view = "prefs"; + needsPaint(); + if (prefs) return; + const res = await net.userRequest("GET", "/api/mail-status"); + if (res.status === 200) prefs = res; + needsPaint(); + }); + + if (busy) return; + + readBtn?.act(e, async () => { + busy = true; + needsPaint(); + const res = await net.userRequest("POST", "/api/mail", { action: "read" }); + if (res.status === 200) { + mail.unread = 0; + mail.inbox.forEach((letter) => (letter.read = true)); + } + busy = false; + needsPaint(); + }); + + if (view !== "prefs" || !prefs) return; + + const toggle = async (action, subscribed) => { + busy = true; + needsPaint(); + const res = await net.userRequest("POST", "/api/mail-status", { action }); + if (res.status === 200) prefs.subscribed = subscribed; + busy = false; + needsPaint(); + }; + + if (prefs.subscribed) { + unsubBtn?.act(e, () => toggle("unsubscribe", false)); } else { - subBtn?.act(e, async () => { - toggling = true; - needsPaint(); - try { - const res = await net.userRequest("POST", "/api/mail-status", { - action: "subscribe", - }); - if (res.status === 200) { - data.subscribed = true; - } - } catch (err) { - console.error("Subscribe failed:", err); - } - toggling = false; - needsPaint(); - }); + subBtn?.act(e, () => toggle("subscribe", true)); } } diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index 87961ea03e..9df271f279 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -2910,6 +2910,36 @@ async function halt($, text) { } makeFlash($); return true; + } else if (slug === "mail" && params.length > 0) { + // mail @handle (or ac25namuc) your message — bare `mail` falls through + // below and loads the inbox piece instead. + const to = params[0]; + const body = params.slice(1).join(" ").trim(); + if (!body) { + flashColor = [255, 255, 0]; + notice("MAIL WHAT?", ["yellow", "red"]); + makeFlash($, true); + return true; + } + const res = await net.userRequest("POST", "/api/mail", { to, text: body }); + if (res?.status === 200) { + flashColor = [0, 255, 0]; + notice(`MAILED ${(res.to || to).toUpperCase()}`); + } else if (res?.status === 404) { + flashColor = [255, 0, 0]; + notice("NO SUCH USER", ["yellow", "red"]); + makeFlash($, true); + } else if (res?.status === 401 || res?.message === "unauthorized") { + flashColor = [255, 0, 0]; + notice("LOG IN TO MAIL", ["yellow", "red"]); + makeFlash($, true); + } else { + flashColor = [255, 0, 0]; + notice("MAIL FAILED", ["yellow", "red"]); + makeFlash($, true); + } + makeFlash($); + return true; } else if (text.startsWith("publish")) { const publishablePiece = store["publishable-piece"]; if (!publishablePiece) {