From e8157fcb36bd6352336fbfbb4b03df7bd896f8d5 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 23:22:30 -0400 Subject: [PATCH] amail: the door learns who it trusts, and letters can leave the wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbound hardening. A letter now needs the stamp Google's routing rule puts in X-Amail-Route, so only OUR tenant's route is honoured even from Google's own relays. Google's Authentication-Results are read: a DMARC failure is refused, and spf/dkim/dmarc ride along on the row so the inbox can mark a sender the gate couldn't vouch for. A sender→box pair gets twenty letters and three buzzes an hour; the door as a whole, sixty a minute. Dedupe is scoped to the box. The service sheds root — CAP_NET_BIND_SERVICE alone opens :25, and lith-mail-renew.sh hands the unprivileged user its own copy of the certificate at deploy and on every renewal. Outbound, decided earlier, now built: a letter to an address nobody here signed up with leaves through Google's SMTP relay as "@handle via Amail " with Reply-To handle@aesthetic.computer — signed by the post office, never as the handle, and answerable at either address. Tapping an outside letter in Amail replies to it; the sent tab marks what left. Addresses read at the root domain now: jeffrey@aesthetic.computer. Co-Authored-By: Claude Fable 5.1 --- lith/deploy.fish | 3 + lith/lith-mail-renew.sh | 22 ++++ lith/lith-mail.service | 15 ++- lith/mail-inbound.mjs | 104 ++++++++++++++++-- system/backend/mail.mjs | 94 ++++++++++++++-- system/netlify/functions/mail.mjs | 22 +++- .../public/aesthetic.computer/disks/amail.mjs | 26 +++-- .../disks/common/laklok-tema.mjs | 4 + 8 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 lith/lith-mail-renew.sh diff --git a/lith/deploy.fish b/lith/deploy.fish index e49220f7a8..c612899f3a 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -311,6 +311,9 @@ echo -e "$GREEN-> Updating service + Caddy config...$NC" ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ cp $REMOTE_DIR/lith/lith.service /etc/systemd/system/lith.service && \ cp $REMOTE_DIR/lith/lith-mail.service /etc/systemd/system/lith-mail.service && \ +id -u lith-mail >/dev/null 2>&1 || useradd --system --shell /usr/sbin/nologin --home-dir /nonexistent lith-mail && \ +install -m 755 $REMOTE_DIR/lith/lith-mail-renew.sh /etc/letsencrypt/renewal-hooks/deploy/lith-mail.sh 2>/dev/null; \ +sh $REMOTE_DIR/lith/lith-mail-renew.sh && \ systemctl enable -q lith-mail && \ cp $REMOTE_DIR/lith/Caddyfile /etc/caddy/Caddyfile && \ mkdir -p /var/lib/aesthetic-computer/gym.anthonyzollo.com && \ diff --git a/lith/lith-mail-renew.sh b/lith/lith-mail-renew.sh new file mode 100644 index 0000000000..ff120e7f7b --- /dev/null +++ b/lith/lith-mail-renew.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# lith-mail-renew — hand the Amail door its certificate. +# +# certbot keeps the Let's Encrypt cert for inbound.aesthetic.computer under +# /etc/letsencrypt, readable by root only. lith-mail runs as its own user, so +# after every issue/renewal (this is the certbot deploy hook) — and once at +# deploy — the pair is copied into /etc/lith-mail for that user, and the door +# is restarted to offer it. Idempotent; safe to run when there is no cert yet. +set -e +HOST=inbound.aesthetic.computer +LIVE=/etc/letsencrypt/live/$HOST +DEST=/etc/lith-mail + +install -d -o lith-mail -g lith-mail -m 700 "$DEST" +if [ -f "$LIVE/fullchain.pem" ] && [ -f "$LIVE/privkey.pem" ]; then + install -o lith-mail -g lith-mail -m 600 "$LIVE/fullchain.pem" "$DEST/fullchain.pem" + install -o lith-mail -g lith-mail -m 600 "$LIVE/privkey.pem" "$DEST/privkey.pem" + systemctl try-restart lith-mail 2>/dev/null || true + echo "lith-mail: certificate for $HOST installed" +else + echo "lith-mail: no certificate for $HOST yet" +fi diff --git a/lith/lith-mail.service b/lith/lith-mail.service index f128f7690e..40de4c30de 100644 --- a/lith/lith-mail.service +++ b/lith/lith-mail.service @@ -4,12 +4,21 @@ After=network.target [Service] Type=simple -User=root +# Not root. Port 25 comes from the capability alone; the certificate is a +# copy in /etc/lith-mail owned by this user (see lith-mail-renew.sh). +User=lith-mail +Group=lith-mail +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true WorkingDirectory=/opt/ac/lith EnvironmentFile=/opt/ac/system/.env ExecStart=/usr/bin/node mail-inbound.mjs -# `always`, not on-failure: the door exits cleanly on purpose once Caddy has -# minted its certificate, so it comes back offering STARTTLS. +# `always`, not on-failure: the door exits cleanly on purpose once its +# certificate first appears, so it comes back offering STARTTLS. Restart=always RestartSec=5 TimeoutStopSec=15 diff --git a/lith/mail-inbound.mjs b/lith/mail-inbound.mjs index e85b933fe3..e9bd992ad8 100644 --- a/lith/mail-inbound.mjs +++ b/lith/mail-inbound.mjs @@ -108,6 +108,8 @@ export function letterText(parsed) { // Caddy's own store is checked second in case that ever changes. function tlsFor(host) { const places = [ + // lith-mail-renew.sh copies the pair here for the unprivileged user. + ["/etc/lith-mail/privkey.pem", "/etc/lith-mail/fullchain.pem"], [`/etc/letsencrypt/live/${host}/privkey.pem`, `/etc/letsencrypt/live/${host}/fullchain.pem`], [ `/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${host}/${host}.key`, @@ -122,10 +124,69 @@ function tlsFor(host) { return null; } +// 🛂 What Google's gate found out about the sender. Google stamps +// Authentication-Results on every letter it accepts; we read spf/dkim/dmarc +// and call the letter verified when DMARC passed, or SPF and DKIM both did. +// A DMARC failure the sender's own policy let through (p=none) is refused +// here — a letter that claims to be from a domain that disowns it. +export function authFrom(parsed) { + const raw = parsed.headers?.get?.("authentication-results"); + const lines = Array.isArray(raw) ? raw : raw ? [raw] : []; + const out = {}; + for (const line of lines) { + const text = typeof line === "string" ? line : line?.value || ""; + for (const m of text.matchAll(/\b(spf|dkim|dmarc)=(\w+)/gi)) { + out[m[1].toLowerCase()] ??= m[2].toLowerCase(); + } + } + out.verified = out.dmarc === "pass" || (out.spf === "pass" && out.dkim === "pass"); + return out; +} + +// 🚦 Rate limits, in memory (the door is one process). A sender→box pair +// gets `perPairPerHour` letters and `pushPerPair` buzzes an hour; the door +// as a whole takes `perMinute`. Over the pair's share is a 550 (Google +// bounces it to the sender); over the door's is a 451 (Google tries later). +export function makeLimiter({ perPairPerHour = 20, pushPerPair = 3, perMinute = 60 } = {}, now = Date.now) { + const pairs = new Map(); + const minute = []; + const prune = (arr, span, t) => { + while (arr.length && t - arr[0] > span) arr.shift(); + }; + return { + take(pair) { + const t = now(); + prune(minute, 60_000, t); + if (minute.length >= perMinute) return { ok: false, code: 451, why: "The door is busy, try later" }; + const hits = pairs.get(pair) || []; + prune(hits, 3_600_000, t); + if (hits.length >= perPairPerHour) return { ok: false, code: 550, why: "Too many letters to this box this hour" }; + hits.push(t); + pairs.set(pair, hits); + minute.push(t); + if (pairs.size > 5000) for (const [k, v] of pairs) if (!v.length || t - v[v.length - 1] > 3_600_000) pairs.delete(k); + return { ok: true, quiet: hits.length > pushPerPair }; + }, + }; +} + // 🚪 Build the server. `lookup(local) → sub | undefined` and -// `file({ sub, rcpt, parsed }) → result` are handed in so a test can run the -// whole SMTP conversation against a fake mailbox. -export function createInbound({ lookup, file, domains, relays = null, open = false, tls = null, log = console.log }) { +// `file({ sub, rcpt, parsed, auth, quiet }) → result` are handed in so a +// test can run the whole SMTP conversation against a fake mailbox. `secret` +// is the value Google's routing rule stamps into X-Amail-Route: with it set, +// a letter that reached us some other way — another tenant's route, say — +// is refused even though it came from a Google relay. +export function createInbound({ + lookup, + file, + domains, + relays = null, + open = false, + tls = null, + secret = null, + limiter = makeLimiter(), + log = console.log, +}) { const server = new SMTPServer({ name: HOST, banner: "Amail — aesthetic.computer", @@ -162,14 +223,38 @@ export function createInbound({ lookup, file, domains, relays = null, open = fal if (stream.sizeExceeded) { return cb(Object.assign(new Error("Letter too large"), { responseCode: 552 })); } + const sender = (parsed.from?.value?.[0]?.address || "?").toLowerCase(); + + // Only letters that came through OUR routing rule carry the stamp. + if (secret && parsed.headers?.get?.("x-amail-route") !== secret) { + log(`✋ refused ${sender} — no route stamp`); + return cb(Object.assign(new Error("Not our route"), { responseCode: 550 })); + } + + const auth = authFrom(parsed); + if (auth.dmarc === "fail") { + log(`✋ refused ${sender} — DMARC failed`); + return cb(Object.assign(new Error("Sender's domain disowns this letter"), { responseCode: 550 })); + } + const results = []; + let refused = null; for (const rcpt of session.amail?.values() || []) { - results.push(await file({ ...rcpt, parsed, remote: session.remoteAddress })); + const gate = limiter.take(`${sender}→${rcpt.sub}`); + if (!gate.ok) { + refused = refused || gate; + log(`✋ ${sender} → ${rcpt.local}: ${gate.why}`); + continue; + } + results.push(await file({ ...rcpt, parsed, auth, quiet: gate.quiet, remote: session.remoteAddress })); + } + if (!results.length && refused) { + return cb(Object.assign(new Error(refused.why), { responseCode: refused.code })); } const summary = results - .map((r) => (r.duplicate ? `${r.toHandle} (again)` : r.toHandle)) + .map((r) => (r.duplicate ? `${r.toHandle} (again)` : r.quiet ? `${r.toHandle} (quiet)` : r.toHandle)) .join(", "); - log(`📬 ${parsed.from?.value?.[0]?.address || "?"} → ${summary || "nobody"}`); + log(`📬 ${sender} → ${summary || "nobody"}${auth.verified ? "" : " · unverified"}`); cb(); } catch (err) { log("🔴 letter failed:", err?.message || err); @@ -207,13 +292,16 @@ async function main() { } const tls = tlsFor(HOST); + const secret = process.env.AMAIL_ROUTE_SECRET || null; + if (!secret) console.log("🟡 AMAIL_ROUTE_SECRET is unset — any Google tenant's route would be accepted"); const server = createInbound({ domains: INBOUND_DOMAINS, relays, open: OPEN, tls, + secret, lookup: (local) => subFromAddress(local, database), - file: async ({ sub, parsed, reply }) => { + file: async ({ sub, parsed, reply, auth, quiet }) => { const sender = parsed.from?.value?.[0] || {}; return deliverFromOutside( { @@ -223,6 +311,8 @@ async function main() { subject: clean(reply ? `re: ${parsed.subject || ""}` : parsed.subject, MAX_SUBJECT_LENGTH), text: clean(letterText(parsed), OUTSIDE_TEXT_LENGTH) || "(an empty letter)", messageId: parsed.messageId || null, + auth, + quiet, }, database, ); diff --git a/system/backend/mail.mjs b/system/backend/mail.mjs index 11aeb8d3c3..be344a9823 100644 --- a/system/backend/mail.mjs +++ b/system/backend/mail.mjs @@ -13,10 +13,15 @@ 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 -// The root domain is Google Workspace's; it catches every unknown -// @aesthetic.computer address and hands the letter to lith/mail-inbound.mjs. -export const INBOUND_DOMAINS = ["aesthetic.computer", MAIL_DOMAIN]; +// Since 26.09.13 the root domain is the address: Google Workspace holds its +// MX, catches every unknown @aesthetic.computer, and hands the letter to +// lith/mail-inbound.mjs. `mail.` stays an alias for the older spelling. +export const ROOT_DOMAIN = "aesthetic.computer"; +export const MAIL_DOMAIN = "mail.aesthetic.computer"; +export const INBOUND_DOMAINS = [ROOT_DOMAIN, MAIL_DOMAIN]; +// The post office's own mailbox. Outbound letters are signed by it, with the +// writer's permahandle in the plus-tag so a reply finds its way home. +export const POST_OFFICE = "amail"; export const MAX_TEXT_LENGTH = 500; export const OUTSIDE_TEXT_LENGTH = 2000; // an email runs longer than a tell export const MAX_SUBJECT_LENGTH = 80; @@ -27,7 +32,9 @@ const PERMAHANDLE = /^ac\d\d[a-z]{5}$/; // see lib/user-code.mjs 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)); + for (const domain of INBOUND_DOMAINS) { + if (to.toLowerCase().endsWith("@" + domain)) to = to.slice(0, -(domain.length + 1)); + } if (PERMAHANDLE.test(to)) { const user = await database.db .collection("users") @@ -57,8 +64,8 @@ export async function addressesFor(sub, database) { .findOne({ _id: sub }, { projection: { code: 1 } }), ]); const out = []; - if (user?.code) out.push(user.code + "@" + MAIL_DOMAIN); - if (handle) out.push(handle + "@" + MAIL_DOMAIN); + if (user?.code) out.push(user.code + "@" + ROOT_DOMAIN); + if (handle) out.push(handle + "@" + ROOT_DOMAIN); return out; } @@ -127,14 +134,22 @@ export async function deliver( // A letter from outside the wall. Google Workspace catches the address and // lith/mail-inbound.mjs hands it here over SMTP. There is no sender `sub` — // the sender lives in `fromEmail` (and `fromHandle` carries their name so the -// inbox reads the same as an inside letter). `messageId` keeps a relay retry -// from filing the same letter twice. +// inbox reads the same as an inside letter). `auth` is what Google's gate +// found out about the sender (spf/dkim/dmarc, and `verified`); `quiet` files +// the letter without buzzing a phone, for a sender who has already buzzed it +// enough this hour. `messageId` keeps a relay retry from filing the same +// letter twice — scoped to the box, so nobody can pre-empt another's letter. +let dedupeIndexed = false; export async function deliverFromOutside( - { to, fromEmail, fromName, subject, text, messageId }, + { to, fromEmail, fromName, subject, text, messageId, auth = null, quiet = false }, database, ) { const tells = await mailbox(database); - await tells.createIndex({ messageId: 1 }, { unique: true, sparse: true }); + if (!dedupeIndexed) { + await tells.createIndex({ to: 1, messageId: 1 }, { unique: true, sparse: true }); + await tells.dropIndex("messageId_1").catch(() => {}); // the first cut's global one + dedupeIndexed = true; + } const toHandle = await nameFor(to, database); const fromHandle = (fromName || "").trim() || fromEmail; const when = new Date(); @@ -150,6 +165,7 @@ export async function deliverFromOutside( text, ...(subject ? { subject } : {}), ...(messageId ? { messageId } : {}), + ...(auth ? { auth } : {}), via: "smtp", when, read: false, @@ -160,6 +176,7 @@ export async function deliverFromOutside( } let push = { attempted: 0, succeeded: 0, failed: 0, pruned: 0 }; + if (quiet) return { id: insertedId, fromHandle, toHandle, when, push, quiet }; try { push = await sendToUser( database.db, @@ -183,3 +200,58 @@ export async function deliverFromOutside( return { id: insertedId, fromHandle, toHandle, when, push }; } + +// A letter leaving the wall. Signed by the post office, never as the handle: +// +// From: @jeffrey via Amail +// Reply-To: jeffrey@aesthetic.computer +// +// The recipient sees who wrote, the signature stays honest (the SPF and DKIM +// are the post office's), and a reply to either address comes back through +// the door — the plus-tag carries the permahandle, which outlives a rename. +// It leaves through Google's SMTP relay with the mail@ credentials; the relay +// lets any address in the domain sign, which plain smtp.gmail.com would not. +export async function sendOutside({ from, toEmail, subject, text }, database) { + const nodemailer = (await import("nodemailer")).default; + const [handle, user] = await Promise.all([ + handleFor(from), + database.db + .collection("users") + .findOne({ _id: from }, { projection: { code: 1 } }), + ]); + const code = user?.code; + if (!code && !handle) throw new Error("a letter needs a handle to be signed"); + const fromHandle = handle ? "@" + handle : code; + const home = `${handle || code}@${ROOT_DOMAIN}`; + + const transporter = nodemailer.createTransport({ + host: process.env.AMAIL_SMTP_SERVER || "smtp-relay.gmail.com", + port: 587, + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }); + const info = await transporter.sendMail({ + from: { name: `${fromHandle} via Amail`, address: `${POST_OFFICE}+${code || handle}@${ROOT_DOMAIN}` }, + replyTo: home, + to: toEmail, + subject: subject || `a letter from ${fromHandle}`, + text: `${text}\n\n— ${fromHandle}, via Amail · reply to ${home}`, + }); + + const tells = await mailbox(database); + const when = new Date(); + const { insertedId } = await tells.insertOne({ + to: null, + toHandle: toEmail, + toEmail, + from, + fromHandle, + text, + ...(subject ? { subject } : {}), + ...(info.messageId ? { messageId: info.messageId } : {}), + via: "smtp-out", + when, + read: true, + }); + return { id: insertedId, fromHandle, toHandle: toEmail, when }; +} diff --git a/system/netlify/functions/mail.mjs b/system/netlify/functions/mail.mjs index 2cc947e468..4b7f08a9ad 100644 --- a/system/netlify/functions/mail.mjs +++ b/system/netlify/functions/mail.mjs @@ -16,6 +16,7 @@ import { deliver, mailbox, MAX_SUBJECT_LENGTH, + sendOutside, subFromAddress, } from "../../backend/mail.mjs"; import { ObjectId } from "mongodb"; @@ -60,6 +61,7 @@ export async function handler(event) { id: m._id, from: m.fromHandle, fromEmail: m.fromEmail || null, // set when the letter came from outside + auth: m.auth || null, // what Google's gate found out about that sender subject: m.subject || null, text: m.text, when: m.when, @@ -68,6 +70,7 @@ export async function handler(event) { sent: sent.map((m) => ({ id: m._id, to: m.toHandle, + toEmail: m.toEmail || null, // set when the letter left the wall subject: m.subject || null, text: m.text, when: m.when, @@ -97,7 +100,24 @@ export async function handler(event) { if (!text) return respond(400, { message: "Empty message" }); const to = await subFromAddress(body.to, database); - if (!to) return respond(404, { message: "Recipient not found" }); + if (!to) { + // 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)) { + return respond(404, { message: "Recipient not found" }); + } + const sent = await sendOutside( + { from: user.sub, toEmail, subject, text }, + database, + ); + return respond(200, { + status: "mailed", + to: sent.toHandle, + when: sent.when, + outside: true, + }); + } const sentMail = await deliver( { from: user.sub, to, text, subject, device: body.device }, diff --git a/system/public/aesthetic.computer/disks/amail.mjs b/system/public/aesthetic.computer/disks/amail.mjs index bf39535c13..16fd18b6ea 100644 --- a/system/public/aesthetic.computer/disks/amail.mjs +++ b/system/public/aesthetic.computer/disks/amail.mjs @@ -471,16 +471,27 @@ function paint(api) { body + 17, ); - rows.push({ y0: y - 3, y1: y + body + 14, who }); + // An answer to an outside letter goes back out as email; act() reads + // `email` off the row for that. + const email = view === "inbox" ? letter.fromEmail : letter.toEmail; + rows.push({ y0: y - 3, y1: y + body + 14, who, email }); if (unread) ink(c.log).box(x + 2, y + 2, 3, 3); ink(unread ? c.handle : c.timestamp).write(who, { x: x + 8, y }); // A letter from outside the wall carries the sender's address after - // their name, small, so an email reads apart from a handle at a glance. + // their name, small, so an email reads apart from a handle at a glance — + // and a word of warning when Google's gate couldn't vouch for the sender. let afterWho = x + 8 + (who.length + 1) * 6; - if (letter.fromEmail && letter.fromEmail !== who) { - ink([...c.timestamp, 170]).write(letter.fromEmail, { x: afterWho, y: y + 2 }, undefined, undefined, false, CHIP_FONT); - afterWho += letter.fromEmail.length * 4 + 6; + if (email && email !== who) { + ink([...c.timestamp, 170]).write(email, { x: afterWho, y: y + 2 }, undefined, undefined, false, CHIP_FONT); + afterWho += email.length * 4 + 6; + } + if (view === "inbox" && letter.fromEmail && letter.auth && !letter.auth.verified) { + ink(255, 140, 140).write(s.unverified, { x: afterWho, y: y + 2 }, undefined, undefined, false, CHIP_FONT); + afterWho += s.unverified.length * 4 + 6; + } else if (view === "sent" && letter.toEmail) { + ink([...c.timestamp, 170]).write(s.outside, { x: afterWho, y: y + 2 }, undefined, undefined, false, CHIP_FONT); + afterWho += s.outside.length * 4 + 6; } if (letter.subject) { ink(unread ? c.painting : [...c.painting, 150]).write( @@ -670,8 +681,9 @@ function act(api) { // Tap a letter to answer it — the field opens already addressed. if (e.is("touch") && (view === "inbox" || view === "sent")) { const row = rows.find((r) => e.y >= r.y0 && e.y < r.y1); - if (row?.who?.startsWith("@")) { - compose(api, row.who); + const address = row?.who?.startsWith("@") ? row.who : row?.email; + if (address) { + compose(api, address); needsPaint(); } } diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs index f189a6fe67..e852af22bd 100644 --- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -273,6 +273,8 @@ const STRINGS = { unsubscribe: "afmeld", blastHistory: "udsendelser", noBlasts: "ingen udsendelser endnu", + unverified: "ubekræftet", + outside: "udefra", error: "fejl", adTitle: "breve mellem @handles", adBody: @@ -317,6 +319,8 @@ const STRINGS = { unsubscribe: "unsubscribe", blastHistory: "blast history", noBlasts: "no blasts sent yet", + unverified: "unverified", + outside: "outside", error: "error", adTitle: "letters between @handles", adBody: -- 2.51.2