diff --git a/lith/mail-inbound.mjs b/lith/mail-inbound.mjs index ec4de05f3d..5df76cd89c 100644 --- a/lith/mail-inbound.mjs +++ b/lith/mail-inbound.mjs @@ -23,6 +23,7 @@ import { BlockList } from "node:net"; import { resolveTxt } from "node:dns/promises"; import { existsSync, readFileSync } from "node:fs"; import { mailErrorCode } from "../shared/mail-privacy.mjs"; +import { incomingAttachments, MAX_MAIL_WIRE_BYTES } from "../system/backend/mail-media.mjs"; const HOST = process.env.AMAIL_INBOUND_HOST || "inbound.aesthetic.computer"; const args = process.argv.slice(2); @@ -33,7 +34,7 @@ const opt = (name, fallback) => { }; const PORT = Number(opt("--port", process.env.AMAIL_INBOUND_PORT || 25)); const OPEN = flag("--open") || process.env.AMAIL_INBOUND_OPEN === "1"; -const MAX_SIZE = 1_000_000; // a letter, not an attachment service +const MAX_SIZE = MAX_MAIL_WIRE_BYTES; // 🏷️ Which mailbox an envelope recipient means. Pure, so it can be tested // without a server: returns { local, tag, domain } or null when the address @@ -226,10 +227,19 @@ export function createInbound({ async onData(stream, session, cb) { try { - const parsed = await simpleParser(stream, { skipImageLinks: true }); - if (stream.sizeExceeded) { + // Drain an oversized message without buffering it or passing it to + // mailparser. SMTP's advertised SIZE alone doesn't bound parser memory. + const chunks = []; + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + if (size <= MAX_SIZE) chunks.push(chunk); + else chunks.length = 0; + } + if (size > MAX_SIZE || stream.sizeExceeded) { return cb(Object.assign(new Error("Letter too large"), { responseCode: 552 })); } + const parsed = await simpleParser(Buffer.concat(chunks), { skipImageLinks: true }); const sender = (parsed.from?.value?.[0]?.address || "?").toLowerCase(); // Only letters that came through OUR routing rule carry the stamp. @@ -244,6 +254,10 @@ export function createInbound({ return cb(Object.assign(new Error("Sender's domain disowns this letter"), { responseCode: 550 })); } + // Inline MIME images and regular attachments take the same private + // path. Validate the entire letter before filing it for any recipient. + const attachments = incomingAttachments(parsed.attachments); + const results = []; let refused = null; for (const rcpt of session.amail?.values() || []) { @@ -253,7 +267,7 @@ export function createInbound({ log("mail.inbound.refused.rate"); continue; } - results.push(await file({ ...rcpt, parsed, auth, quiet: gate.quiet, remote: session.remoteAddress })); + results.push(await file({ ...rcpt, parsed, attachments, auth, quiet: gate.quiet, remote: session.remoteAddress })); } if (!results.length && refused) { return cb(Object.assign(new Error(refused.why), { responseCode: refused.code })); @@ -266,6 +280,9 @@ export function createInbound({ }); cb(); } catch (err) { + if (err.responseCode === 552) { + return cb(Object.assign(new Error("At most 10 files and 8 MiB of attachments per letter"), { responseCode: 552 })); + } log("mail.inbound.file.error", mailErrorCode(err)); cb(Object.assign(new Error("Could not file that letter"), { responseCode: 451 })); } @@ -310,7 +327,7 @@ async function main() { tls, secret, lookup: (local) => subFromAddress(local, database), - file: async ({ sub, parsed, reply, auth, quiet }) => { + file: async ({ sub, parsed, attachments, reply, auth, quiet }) => { const sender = parsed.from?.value?.[0] || {}; return deliverFromOutside( { @@ -322,6 +339,7 @@ async function main() { messageId: parsed.messageId || null, auth, quiet, + attachments, }, database, ); diff --git a/spec/mail-media-spec.mjs b/spec/mail-media-spec.mjs new file mode 100644 index 0000000000..4ce1bd2b61 --- /dev/null +++ b/spec/mail-media-spec.mjs @@ -0,0 +1,162 @@ +// node --experimental-vm-modules spec/mail-media-spec.mjs +// Real MIME/SMTP and production handlers; isolated in-memory mailbox/auth. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import vm from 'node:vm'; +import { ObjectId, BSON } from 'mongodb'; +import nodemailer from 'nodemailer'; +import sharp from 'sharp'; +import { simpleParser } from '../lith/node_modules/mailparser/index.js'; +import { createInbound, letterText } from '../lith/mail-inbound.mjs'; +import * as media from '../system/backend/mail-media.mjs'; +import * as privacy from '../shared/mail-privacy.mjs'; +import { respond } from '../system/backend/http.mjs'; + +const logs = [], rows = [], deliveries = []; +const match = (row, query) => Object.entries(query).every(([key, expected]) => { + if (key === '$or') return expected.some((q) => match(row, q)); + if (expected && typeof expected === 'object' && !(expected instanceof ObjectId)) { + if ('$ne' in expected) return row[key] !== expected.$ne; + if ('$in' in expected) return expected.$in.some((v) => v == null ? row[key] == null : row[key] === v); + } + return String(row[key]) === String(expected); +}); +const noBytes = (row) => ({ ...row, attachments: row.attachments?.map(({ data, ...file }) => file) }); +const tells = { + createIndex: async () => {}, dropIndex: async () => {}, + insertOne: async (row) => { + if (row.messageId && rows.some((r) => r.to === row.to && r.messageId === row.messageId)) throw Object.assign(new Error('duplicate'), { code: 11000 }); + const _id = new ObjectId(); rows.push({ ...row, _id }); return { insertedId: _id }; + }, + findOne: async (query) => rows.find((row) => match(row, query)), + find: (query, options) => { + assert.equal(options?.projection?.['attachments.data'], 0, 'listing must exclude bytes at the database'); + const cursor = { sort: () => cursor, limit: () => cursor, toArray: async () => rows.filter((row) => match(row, query)).map(noBytes) }; + return cursor; + }, + countDocuments: async (query) => rows.filter((row) => match(row, query)).length, +}; +const publicRows = { + paintings: [{ code: 'art' }, { code: 'secret', private: true }, { code: 'gone', nuked: true }, { code: 'draft', draft: true }, { code: 'hide', hidden: true }, { code: 'delete', deleted: true }, { code: 'unlisted', visibility: 'unlisted' }], + tapes: [{ code: 'mov' }], kidlisp: [{ code: 'abc' }], +}; +const database = { db: { collection: (name) => { + if (name === 'tells') return tells; + if (name === 'users') return { findOne: async () => ({ code: 'ac25abcde' }) }; + assert.ok(publicRows[name], `unexpected collection ${name}`); + return { findOne: async (q) => publicRows[name].find((row) => match(row, q)) }; +} }, disconnect: async () => {} }; +let identity = { sub: 'recipient' }; +const context = vm.createContext({ console: { error: (...args) => logs.push(args) }, Buffer }); +async function load(path, mocks) { + const mod = new vm.SourceTextModule(await readFile(new URL(path, import.meta.url), 'utf8'), { + context, importModuleDynamically: (name) => synthetic(name), + }); + async function synthetic(name) { + assert.ok(mocks[name], `mock ${name}`); + const module = new vm.SyntheticModule(Object.keys(mocks[name]), function () { + for (const [key, value] of Object.entries(mocks[name])) this.setExport(key, value); + }, { context }); + await module.link(() => {}); await module.evaluate(); return module; + } + await mod.link(synthetic); await mod.evaluate(); return mod.namespace; +} +context.process = { env: {} }; +const backend = await load('../system/backend/mail.mjs', { + './authorization.mjs': { handleFor: async (sub) => sub, userIDFromHandleOrEmail: async () => 'recipient' }, + './filter.mjs': { filter: (s) => s }, './shell.mjs': { shell: { log: (...args) => logs.push(args) } }, + './mail-media.mjs': media, '../../shared/mail-privacy.mjs': privacy, + '../../shared/push.mjs': { sendToUser: async () => ({ attempted: 0, failed: 0 }) }, + nodemailer: { default: { createTransport: () => ({ sendMail: async (letter) => { + const sent = await nodemailer.createTransport({ streamTransport: true, buffer: true }).sendMail(letter); + deliveries.push(sent.message); return sent; + } }) } }, +}); +const api = await load('../system/netlify/functions/mail.mjs', { + '../../backend/authorization.mjs': { authorize: async () => identity }, + '../../backend/database.mjs': { connect: async () => database }, + '../../backend/http.mjs': { respond }, '../../backend/mail.mjs': backend, + '../../backend/mail-media.mjs': media, '../../../shared/mail-privacy.mjs': privacy, + mongodb: { ObjectId }, +}); +const get = (query = {}) => api.handler({ httpMethod: 'GET', headers: {}, queryStringParameters: query }); +const json = (res) => JSON.parse(res.body); + +assert.deepEqual(media.mediaCodes('see #art, !mov and $abc. #art https://aesthetic.computer/#art').map((r) => r.label), ['#art', '!mov', '$abc']); +assert.deepEqual(media.mediaCodes('https://other.invalid/#art user#art@test.invalid abc#art #x'), []); +const refs = await media.resolveMailMedia('#art !mov $abc #secret #gone #draft #hide #delete #unlisted #missing', database); +assert.deepEqual(refs.map((r) => r.label), ['#art', '!mov', '$abc']); +assert.deepEqual(refs.map((r) => r.path), ['painting#art', 'video~!mov', '$abc']); +await backend.sendOutside({ from: 'sender', toEmail: 'outside@example.invalid', subject: 'Media', text: ' #art !mov $abc' }, database); +const outgoing = await simpleParser(deliveries[0]); +assert.match(outgoing.text, /https:\/\/aesthetic.computer\/#art/); +assert.match(outgoing.html, /')); +assert.match(outgoing.html, /<script>/); +assert.equal(outgoing.attachments.length, 0, 'outgoing mail references existing media, without copying files'); + +const png = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#308fc0' } }).png().toBuffer(); +const pdf = Buffer.from('%PDF-1.4\nPRIVATE_LETTER_CANARY\n\x00\xff', 'latin1'); +const server = createInbound({ domains: ['example.invalid'], open: true, + lookup: async () => 'recipient', log: (...args) => logs.push(args), + file: ({ parsed, attachments, sub }) => backend.deliverFromOutside({ + to: sub, fromEmail: 'outside@example.invalid', text: letterText(parsed) || '(an empty letter)', + messageId: parsed.messageId, attachments, quiet: true, + }, database), +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const transport = nodemailer.createTransport({ host: '127.0.0.1', port: server.server.address().port, secure: false, ignoreTLS: true }); +try { + const letter = { from: 'outside@example.invalid', to: 'recipient@example.invalid', messageId: '', + html: '

Two files

', + attachments: [{ filename: 'photo.png', content: png, cid: 'photo' }, { filename: 'notes.pdf', content: pdf }], + }; + await transport.sendMail(letter); + await transport.sendMail(letter); + const received = rows.filter((row) => row.via === 'smtp'); + assert.equal(received.length, 1, 'relay retries do not duplicate letters/files'); + const row = received[0]; + assert.equal(row.attachments.length, 2); + assert.deepEqual(Buffer.from(row.attachments[0].data, 'base64'), png); + assert.deepEqual(Buffer.from(row.attachments[1].data, 'base64'), pdf); + const list = await get(); + assert.equal(list.statusCode, 200); + assert.equal(json(list).inbox[0].attachments[0].image, true); + assert.ok(!list.body.includes(row.attachments[0].data)); + assert.ok(!list.body.includes(row.attachments[1].data)); + const query = { id: String(row._id), attachment: '1' }; + let download = await get(query); + assert.equal(download.statusCode, 200); + assert.deepEqual(Buffer.from(download.body, 'base64'), pdf); + assert.equal(download.headers['Content-Type'], 'application/octet-stream'); + assert.match(download.headers['Cache-Control'], /no-store/); + assert.match(download.headers['Content-Disposition'], /^attachment;/); + assert.equal((await get({ ...query, attachment: '99' })).statusCode, 404); + assert.equal((await get({ ...query, id: 'bad' })).statusCode, 400); + const preview = await get({ ...query, attachment: '0', preview: '1' }); + assert.equal(preview.statusCode, 200); + const image = await sharp(Buffer.from(json(preview).data, 'base64')).metadata(); + assert.equal(image.width, 320); assert.equal(image.height, 160); + assert.equal((await get({ ...query, preview: '1' })).statusCode, 404); + identity = { sub: 'intruder' }; + for (const options of [{}, { json: '1' }, { preview: '1' }]) assert.equal((await get({ ...query, ...options })).statusCode, 404); + identity = null; + assert.equal((await get(query)).statusCode, 401); + identity = { sub: 'recipient' }; + await transport.sendMail({ from: 'outside@example.invalid', to: 'recipient@example.invalid', attachments: [{ filename: 'image.png', content: png }] }); + assert.equal(rows.filter((r) => r.via === 'smtp').length, 2, 'image-only email is retained'); + const before = rows.length; + await assert.rejects(transport.sendMail({ from: 'outside@example.invalid', to: 'recipient@example.invalid', attachments: [{ filename: 'large.bin', content: Buffer.alloc(media.MAX_MAIL_FILE_BYTES + 1) }] }), (err) => err.responseCode === 552); + await assert.rejects(transport.sendMail({ from: 'outside@example.invalid', to: 'recipient@example.invalid', attachments: Array.from({ length: 11 }, () => ({ filename: 'x.txt', content: 'x' })) }), (err) => err.responseCode === 552); + assert.equal(rows.length, before, 'rejected files do not leave partial letters'); + let rejected; + await server.options.onData(Readable.from([Buffer.alloc(media.MAX_MAIL_WIRE_BYTES), Buffer.from('x')]), {}, (err) => { rejected = err; }); + assert.equal(rejected.responseCode, 552); +} finally { transport.close(); await new Promise((resolve) => server.close(resolve)); } +const max = media.incomingAttachments([{ filename: '../../unsafe\r\n.bin', contentType: 'bad\r\ntype', content: Buffer.alloc(media.MAX_MAIL_FILE_BYTES) }]); +assert.equal(max[0].name, 'unsafe.bin'); assert.equal(max[0].type, 'application/octet-stream'); +assert.ok(BSON.calculateObjectSize({ attachments: max, text: 'x'.repeat(2000) }) < 16 * 1024 * 1024); +assert.equal(await media.attachmentThumbnail({ type: 'image/png', data: 'YmFk' }), null); +assert.ok(!JSON.stringify(logs).includes('PRIVATE_LETTER_CANARY')); +console.log('mail media spec passed: code references, outbound MIME, SMTP inline images/files, retries, byte limits, mailbox authorization, downloads, previews and privacy'); diff --git a/spec/mail-privacy-spec.mjs b/spec/mail-privacy-spec.mjs index 9c0367c5b8..4e9b2e7243 100644 --- a/spec/mail-privacy-spec.mjs +++ b/spec/mail-privacy-spec.mjs @@ -4,6 +4,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import vm from "node:vm"; import * as privacy from "../shared/mail-privacy.mjs"; +import * as media from "../system/backend/mail-media.mjs"; +import { Readable } from "node:stream"; const marker = "PRIVATE_LETTER_CANARY"; const failure = Object.assign(new Error(marker), { code: marker, response: marker }); @@ -11,7 +13,7 @@ const logs = []; const notes = []; const stored = []; const log = (...args) => logs.push(args); -const context = vm.createContext({ console: { log, error: log }, URL, process: { argv: [], env: {} } }); +const context = vm.createContext({ console: { log, error: log }, URL, Buffer, process: { argv: [], env: {} } }); async function load(path, mocks) { const module = new vm.SourceTextModule(await readFile(new URL(path, import.meta.url), "utf8"), { context, @@ -47,6 +49,7 @@ const backend = await load("../system/backend/mail.mjs", { }, "./filter.mjs": { filter: (s) => s }, "./shell.mjs": { shell: { log } }, + "./mail-media.mjs": media, "../../shared/mail-privacy.mjs": privacy, "../../shared/push.mjs": { sendToUser: async (_db, _to, note, options, diagnostic) => { notes.push({ note, options }); @@ -89,6 +92,7 @@ for (const api of ["mail", "tell"]) { return { ...database, disconnect: async () => { if (stage === "disconnect") throw failure; } }; } }, "../../backend/http.mjs": { respond: (status, body) => ({ status, body }) }, + "../../backend/mail-media.mjs": media, "../../backend/mail.mjs": { ...backend, deliver: async (...args) => { @@ -115,6 +119,7 @@ const inbound = await load("../lith/mail-inbound.mjs", { "node:dns/promises": { resolveTxt: async () => [] }, "node:fs": { existsSync: () => false, readFileSync: () => "" }, "../shared/mail-privacy.mjs": privacy, + "../system/backend/mail-media.mjs": media, }); const session = { remoteAddress: "127.0.0.1", amail: new Map([["recipient", { sub: "recipient", local: marker }]]) }; for (const stage of ["filed", "failure", "stamp", "lookup", "relay", "rate"]) { @@ -129,7 +134,7 @@ for (const stage of ["filed", "failure", "stamp", "lookup", "relay", "rate"]) { const cb = (error) => { result = error; }; if (stage === "lookup") await server.options.onRcptTo({ address: `${marker}@example.invalid` }, session, cb); else if (stage === "relay") server.options.onConnect(session, cb); - else await server.options.onData({}, session, cb); + else await server.options.onData(Readable.from([]), session, cb); assert.equal(!!result, stage !== "filed"); if (result) assert.ok(!result.message.includes(marker)); } diff --git a/system/backend/MAIL.md b/system/backend/MAIL.md new file mode 100644 index 0000000000..f815ee4978 --- /dev/null +++ b/system/backend/MAIL.md @@ -0,0 +1,35 @@ +# Mail media + +Letters use chat-style references: `#painting`, `!tape`, and `$kidlisp` codes. +The inbox and sent views resolve public records, show painting previews, and +open each reference in its AC viewer. Unrecognized codes remain text. Outgoing +email includes canonical links in plain text and linked paintings in HTML; +it does not upload or copy media files. + +Incoming SMTP attachments, including inline MIME images, are stored with the +letter in `tells`. They are not published as paintings or MIME posts. The +recipient can preview PNG/JPEG/GIF/WebP images and download any file type. +Remote images in an HTML email are not fetched. + +Limits: 10 files, 8 MiB combined decoded bytes, 12 MiB wire message. An oversized +letter receives SMTP 552 before any recipient's copy is filed. Base64 storage +plus the bounded letter body remains under MongoDB's 16 MiB document limit. +Existing message-ID deduplication also covers the files. + +`GET /api/mail` returns file metadata only. Authenticated downloads use +`?id=&attachment=`. The caller must be the stored recipient +or sender. The default response downloads raw bytes; `&json=1` returns base64 +for the worker bridge, and `&preview=1` returns a bounded PNG thumbnail. All +responses use `private, no-store`. Invalid images remain downloadable. + +Validation (no production data or outbound delivery): + +```sh +npm ci --prefix lith --ignore-scripts +node --experimental-vm-modules spec/mail-privacy-spec.mjs +node --experimental-vm-modules spec/mail-media-spec.mjs +``` + +The media spec exercises real MIME generation and loopback SMTP with an +isolated in-memory mailbox. Deploying this change requires both lith's web +process and `lith-mail.service` to reload the changed modules. diff --git a/system/backend/mail-media.mjs b/system/backend/mail-media.mjs new file mode 100644 index 0000000000..d9473a4bc3 --- /dev/null +++ b/system/backend/mail-media.mjs @@ -0,0 +1,88 @@ +// Mail references public AC media; files arriving by SMTP stay in the letter. +// Eight MiB of base64 stays below MongoDB's 16 MiB document limit. +export const MAX_MAIL_FILES = 10; +export const MAX_MAIL_FILE_BYTES = 8 * 1024 * 1024; +export const MAX_MAIL_WIRE_BYTES = 12 * 1024 * 1024; +const RASTER = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +const KINDS = { "#": "painting", "!": "tape", "$": "kidlisp" }; +const COLLECTIONS = { painting: "paintings", tape: "tapes", kidlisp: "kidlisp" }; +const PUBLIC = { + nuked: { $ne: true }, deleted: { $ne: true }, private: { $ne: true }, + hidden: { $ne: true }, draft: { $ne: true }, visibility: { $in: [null, "public"] }, +}; + +export function mediaCodes(text) { + const found = new Map(); + // Match bare chat codes and canonical AC URLs, but not URL fragments on + // unrelated sites, email local-parts, or pieces of longer words. + const rx = /(?:^|[\s(\[])((?:https:\/\/aesthetic\.computer\/)?([#!$])([a-zA-Z0-9]{3,64}))(?=$|[\s)\].,;:?])/g; + for (const match of (text || "").matchAll(rx)) { + const label = match[2] + match[3]; + found.set(label, { kind: KINDS[match[2]], code: match[3], label }); + if (found.size === 10) break; + } + return [...found.values()]; +} + +export async function resolveMailMedia(text, database) { + const refs = await Promise.all(mediaCodes(text).map(async (ref) => { + const record = await database.db.collection(COLLECTIONS[ref.kind]).findOne( + { ...PUBLIC, code: ref.code }, { projection: { _id: 1 } }, + ); + if (!record) return null; // An ordinary hashtag stays ordinary text. + return { + ...ref, path: ref.kind === "painting" ? `painting${ref.label}` : ref.kind === "tape" ? `video~${ref.label}` : ref.label, + url: `https://aesthetic.computer/${ref.label}`, + ...(ref.kind === "painting" ? { preview: `/media/paintings/${ref.code}.png` } : {}), + }; + })); + return refs.filter(Boolean); +} + +const escapeHTML = (text) => String(text).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +})[c]); + +export function outsideMediaBody(text, media) { + if (!media.length) return { text }; + return { + text: `${text}\n\n${media.map((m) => `${m.label}: ${m.url}`).join("\n")}`, + html: `
${escapeHTML(text)}
` + media.map((m) => + `

${m.preview + ? `${escapeHTML(m.label)}
` + : ""}${escapeHTML(m.label)}

`).join(""), + }; +} + +export function incomingAttachments(files = []) { + let total = 0; + const tooLarge = () => Object.assign(new Error("At most 10 files and 8 MiB of attachments per letter"), { responseCode: 552 }); + if (files.length > MAX_MAIL_FILES) throw tooLarge(); + return files.map((file, index) => { + const bytes = file.content; + total += bytes.length; + if (total > MAX_MAIL_FILE_BYTES) throw tooLarge(); + const name = String(file.filename || `file-${index + 1}`) + .split(/[\\/]/).pop().replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, "").slice(0, 120) || `file-${index + 1}`; + const declared = String(file.contentType || "").toLowerCase(); + const type = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(declared) + ? declared : "application/octet-stream"; + return { name, type, size: bytes.length, data: bytes.toString("base64") }; + }); +} + +export function attachmentList(files = []) { + return files.map(({ name, type, size }, index) => ({ index, name, type, size, image: RASTER.has(type) })); +} + +export async function attachmentThumbnail(file) { + if (!RASTER.has(file.type)) return null; + try { + const sharp = (await import("sharp")).default; + const data = await sharp(Buffer.from(file.data, "base64"), { limitInputPixels: 20_000_000 }) + .resize(320, 240, { fit: "inside", withoutEnlargement: true }).png().toBuffer(); + return { type: "image/png", data: data.toString("base64") }; + } catch { + return null; // A bad image is still downloadable; never log its contents. + } +} diff --git a/system/backend/mail.mjs b/system/backend/mail.mjs index b3a9c802c2..faf1b37d42 100644 --- a/system/backend/mail.mjs +++ b/system/backend/mail.mjs @@ -1,7 +1,5 @@ // 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. +// AC mail: internal letters and the inbound/outbound SMTP bridge share `tells`. // // 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 @@ -13,6 +11,7 @@ import { filter } from "./filter.mjs"; import { shell } from "./shell.mjs"; import { sendToUser } from "../../shared/push.mjs"; import { letterNotification, mailErrorCode, quietMailPush } from "../../shared/mail-privacy.mjs"; +import { resolveMailMedia, outsideMediaBody } from "./mail-media.mjs"; // 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 @@ -134,7 +133,7 @@ export async function deliver( // 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, auth = null, quiet = false }, + { to, fromEmail, fromName, subject, text, messageId, auth = null, quiet = false, attachments = [] }, database, ) { const tells = await mailbox(database); @@ -166,6 +165,7 @@ export async function deliverFromOutside( ...(messageId ? { messageId } : {}), ...(auth ? { auth } : {}), via: "smtp", + ...(attachments.length ? { attachments } : {}), when, read: false, })); @@ -226,7 +226,10 @@ export async function sendOutside({ from, toEmail, subject, text }, database) { replyTo: home, to: toEmail, subject: subject || `a letter from ${fromHandle}`, - text: `${text}\n\n— ${fromHandle}, via aesthetic.computer mail · reply to ${home}`, + ...outsideMediaBody( + `${text}\n\n— ${fromHandle}, via aesthetic.computer mail · reply to ${home}`, + await resolveMailMedia(text, database), + ), }; let info; try { diff --git a/system/netlify/functions/mail.mjs b/system/netlify/functions/mail.mjs index ccaee6af8f..ad6963e9fc 100644 --- a/system/netlify/functions/mail.mjs +++ b/system/netlify/functions/mail.mjs @@ -9,7 +9,8 @@ import { authorize } from "../../backend/authorization.mjs"; import { connect } from "../../backend/database.mjs"; -import { respond } from "../../backend/http.mjs"; +import { respond as httpRespond } from "../../backend/http.mjs"; +import { attachmentList, attachmentThumbnail, resolveMailMedia } from "../../backend/mail-media.mjs"; import { addressesFor, clean, @@ -23,6 +24,8 @@ import { ObjectId } from "mongodb"; import { mailErrorCode } from "../../../shared/mail-privacy.mjs"; const PAGE = 50; +const respond = (status, body, headers = {}) => httpRespond(status, body, { "Cache-Control": "private, no-store", ...headers }); +const NO_FILES = { projection: { "attachments.data": 0 } }; export async function handler(event) { try { @@ -48,6 +51,32 @@ async function handleMail(event) { const tells = await mailbox(database); if (event.httpMethod === "GET") { + const query = event.queryStringParameters || {}; + if (query.attachment !== undefined) { + if (!/^[a-f\d]{24}$/i.test(query.id || "") || !/^\d{1,2}$/.test(query.attachment)) { + return respond(400, { message: "Invalid attachment" }); + } + // Authorize against the letter, never just a guessable file index. + const letter = await tells.findOne({ + _id: new ObjectId(query.id), $or: [{ to: user.sub }, { from: user.sub }], + }, { projection: { attachments: 1 } }); + const file = letter?.attachments?.[Number(query.attachment)]; + if (!file) return respond(404, { message: "Attachment not found" }); + if (query.preview !== undefined) { + const preview = await attachmentThumbnail(file); + return preview ? respond(200, preview) : respond(404, { message: "Preview unavailable" }); + } + if (query.json !== undefined) return respond(200, { name: file.name, type: file.type, data: file.data }); + return { + ...respond(200, file.data, { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="file"; filename*=UTF-8''${encodeURIComponent(file.name).replace(/'/g, "%27")}`, + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "sandbox", + }), + isBase64Encoded: true, + }; + } // `?count` is the cheap one — the prompt asks it on every boot just to // know whether to draw the envelope. if (event.queryStringParameters?.count !== undefined) { @@ -59,16 +88,23 @@ async function handleMail(event) { } 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.find({ to: user.sub }, NO_FILES).sort({ when: -1 }).limit(PAGE).toArray(), + tells.find({ from: user.sub }, NO_FILES).sort({ when: -1 }).limit(PAGE).toArray(), tells.countDocuments({ to: user.sub, read: { $ne: true } }), addressesFor(user.sub, database), ]); + const media = new Map(); + // Cache repeated text within this request, and recheck media visibility + // on each inbox read rather than persisting public preview URLs. + const references = (text) => { + if (!media.has(text)) media.set(text, resolveMailMedia(text, database)); + return media.get(text); + }; return respond(200, { addresses, unread, - inbox: inbox.map((m) => ({ + inbox: await Promise.all(inbox.map(async (m) => ({ id: m._id, from: m.fromHandle, fromEmail: m.fromEmail || null, // set when the letter came from outside @@ -77,15 +113,19 @@ async function handleMail(event) { text: m.text, when: m.when, read: m.read === true, - })), - sent: sent.map((m) => ({ + attachments: attachmentList(m.attachments), + media: await references(m.text), + }))), + sent: await Promise.all(sent.map(async (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, - })), + attachments: attachmentList(m.attachments), + media: await references(m.text), + }))), }); } diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 41a43bc728..49a08c3e98 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -22047,6 +22047,20 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Downloads both cached files via `data` and network stored files for // users and guests. async function receivedDownload({ filename, data, modifiers }) { + // Mail attachments are exact bytes, never painting/tape inputs. Keep + // filenames out of diagnostics and don't dispatch by their extension. + if (modifiers?.private && modifiers?.encoding === "binary" && typeof data === "string") { + const bytes = Uint8Array.from(data, (c) => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); + const link = document.createElement("a"); + link.href = url; + link.download = filename.split(/[\\/]/).pop(); + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + setTimeout(() => { link.remove(); URL.revokeObjectURL(url); }, 60_000); + return; + } console.log("💾 Downloading:", filename); // if (data) console.log("Data:", typeof data); // if (modifiers.sharing === true) presharingFile = true; diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs index 3c2ef86b95..1e2ed2c80b 100644 --- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -256,6 +256,11 @@ const STRINGS = { nothingSent: "intet sendt endnu", tryHint: "prøv: mail @jeffrey hej", composeHint: "enter går ned · tryk på en række", + mediaHint: "#maleri · !bånd · $kidlisp", + download: "hent", + downloading: "henter fil…", + downloadFailed: "filen kunne ikke hentes", + noPreview: "ingen forhåndsvisning", whoTo: "til hvem?", nothingToSay: "intet at sige endnu", noOne: "ingen svarer på", @@ -278,7 +283,7 @@ const STRINGS = { error: "fejl", adTitle: "breve mellem @handles", adBody: - "mail er posten på aesthetic.computer — intet forlader computeren. log ind, tag et @handle, og din boks er klar.", + "mail er posten på aesthetic.computer — til @handles og emailadresser. log ind, tag et @handle, og din boks er klar.", adPrompt: "fra enhver prompt: mail @handle dine ord", signup: "opret dig", login: "log ind", @@ -302,6 +307,11 @@ const STRINGS = { nothingSent: "nothing sent yet", tryHint: "try: mail @jeffrey hello", composeHint: "enter moves down · tap a row to jump", + mediaHint: "#painting · !tape · $kidlisp", + download: "download", + 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", @@ -324,7 +334,7 @@ const STRINGS = { error: "error", adTitle: "letters between @handles", adBody: - "mail is the post of aesthetic.computer — nothing leaves the computer. log in, take a @handle, and your box is ready.", + "mail is the post of aesthetic.computer — to @handles and email addresses. log in, take a @handle, and your box is ready.", adPrompt: "from any prompt: mail @handle your words", signup: "sign up", login: "log in", diff --git a/system/public/aesthetic.computer/disks/common/mail-media.mjs b/system/public/aesthetic.computer/disks/common/mail-media.mjs new file mode 100644 index 0000000000..f4a9b3a78a --- /dev/null +++ b/system/public/aesthetic.computer/disks/common/mail-media.mjs @@ -0,0 +1,88 @@ +// Mail-only previews stay in memory; private bytes never enter the public +// bitmap loader, persistent browser store, or piece diagnostics. +export class MailMedia { + previews = new Map(); + active = 0; + generation = 0; + + clear() { + this.generation++; + this.previews.clear(); + } + + layout(api, letter, width, words) { + const items = [ + ...(letter.media || []).map((ref) => ({ + key: ref.label, label: ref.label, image: !!ref.preview, ref, + })), + ...(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, + })), + ]; + for (const item of items) { + item.face = api.screen.width < 320 || api.screen.height < 220 ? "MatrixChunky8" : undefined; + const labelWidth = Math.max(32, width - (item.image ? 110 : 0)); + const labelH = api.text.box(item.label, undefined, labelWidth, 1, true, item.face).box.height; + item.height = Math.max(item.image ? 80 : 14, labelH + 8); + } + return items; + } + + async load(api, item) { + if (this.previews.has(item.key) || this.active >= 2) return; + const generation = this.generation; + this.previews.set(item.key, { loading: true }); + this.active++; + let image; + try { + if (item.ref) { + image = (await api.get.picture(item.ref.preview)).img; + } else { + const res = await api.net.userRequest("GET", `/api/mail?id=${item.id}&attachment=${item.file.index}&preview=1`); + if (res.status !== 200) throw new Error("Preview unavailable"); + const bytes = Uint8Array.from(atob(res.data), (c) => c.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + try { + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(bitmap, 0, 0); + image = { width: bitmap.width, height: bitmap.height, pixels: ctx.getImageData(0, 0, bitmap.width, bitmap.height).data }; + } finally { bitmap.close(); } + } + } catch { /* Keep the download available even if previewing fails. */ } + finally { + this.active--; + if (generation === this.generation) { + this.previews.set(item.key, { image }); + } + api.needsPaint(); + } + } + + paint(api, item, x, y, width, color, words) { + if (item.image) { + this.load(api, item); + const cached = this.previews.get(item.key); + if (cached?.image) { + const image = cached.image; + const scale = Math.min(104 / image.width, 72 / image.height); + const w = Math.max(1, Math.floor(image.width * scale)); + const h = Math.max(1, Math.floor(image.height * scale)); + api.paste(image, x + Math.floor((104 - w) / 2), y + 4, { width: w, height: h }); + } else { + api.ink(color).write(cached?.loading || !cached ? words.loading : words.noPreview, + { x, y: y + 8 }, undefined, 104, true, item.face); + } + } + const labelX = x + (item.image ? 110 : 0); + api.ink(color).write(item.label, { x: labelX, y: y + 4 }, undefined, width - (labelX - x), true, item.face); + } + + async open(api, item) { + 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"); + api.download(res.name, atob(res.data), { encoding: "binary", private: true }); + } +} diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs index 985253d48c..d2b97dda1e 100644 --- a/system/public/aesthetic.computer/disks/mail.mjs +++ b/system/public/aesthetic.computer/disks/mail.mjs @@ -1,7 +1,7 @@ // Mail, 2026.2.12 → 2026.9.14 (`amail` for a day; that path still aliases here) // The post of aesthetic.computer. `mail @handle words...` sends from the // prompt; `mail` lands here; `mail~@handle` lands in compose, addressed. -// Tier 1: nothing leaves aesthetic.computer. See `system/backend/mail.mjs`. +// Public media codes work like chat; outside attachments stay in the mailbox. // // Mail wears the same tema and speaks the same language as `laklok` — one // saved choice each, one census — so the two rooms read as one house. Its QR @@ -36,6 +36,12 @@ import { temaRow, langRow, } from "./common/laklok-tema.mjs"; +import { MailMedia } from "./common/mail-media.mjs"; + +const mediaView = new MailMedia(); +let mediaHits = []; +let mediaNote = null; +let downloading = false; let view = "inbox"; // inbox · sent · prefs · compose let status = "loading"; // loading, loaded, error, noauth @@ -112,7 +118,7 @@ function makeFields(api) { fields = new api.ui.TextFields( api, [ - { name: "to", label: s.to, placeholder: "@handle or ac25namuc" }, + { name: "to", label: s.to, placeholder: "@handle or email" }, { name: "subject", label: s.re, placeholder: s.optional }, { name: "body", label: s.say, lines: 4, placeholder: "…" }, ], @@ -137,6 +143,9 @@ async function boot(api) { view = "inbox"; composeNote = null; hits = []; + mediaView.clear(); + mediaNote = null; + downloading = false; // 👗 A colon/`~` token pins a tema or language (`mail~skov`, `mail~da`); // otherwise the saved ones. @@ -356,6 +365,7 @@ function paint(api) { const c = t.chat; const s = S(); hits = []; + mediaHits = []; wipe(...t.bg); const x = 6; @@ -435,6 +445,10 @@ function paint(api) { ink(c.lines).box(x, y, wide, 1); y += 6; + if (mediaNote) { + ink(c.timestamp).write(mediaNote, { x, y }, undefined, wide, true, CHIP_FONT); + y += text.box(mediaNote, undefined, wide, 1, true, CHIP_FONT).box.height + 4; + } // Compose sits in the room instead of replacing it — the addresses and tabs // stay put and the field takes the space the letters were using. @@ -443,7 +457,7 @@ function paint(api) { x, y, width: wide, - height: Math.min(110, Math.max(64, screen.height - y - 30)), + height: Math.min(110, Math.max(64, screen.height - y - 40)), }; fields.paint(api, frame); @@ -457,6 +471,7 @@ function paint(api) { false, CHIP_FONT, ); + ink(c.timestamp).write(s.mediaHint, { x, y: footer + CHIP_H + 4 }, undefined, wide, true, CHIP_FONT); paintCorner(api); paintSettings(api); return; @@ -498,7 +513,8 @@ function paint(api) { if (body.length > most) body = body.slice(0, most) + "…"; } const h = text.box(body, { x: x + 10, y: 0 }, bounds, 1, true, face).box.height; - return { letter, body, rowH: h + lh + (compact ? 5 : 8) }; + const mediaItems = mediaView.layout(api, letter, bounds, s); + return { letter, body, mediaItems, textH: h, rowH: h + lh + (compact ? 5 : 8) + mediaItems.reduce((n, item) => n + item.height, 0) }; }); const contentH = measured.reduce((sum, m) => sum + m.rowH, 0); const maxScroll = Math.max(0, contentH - listH); @@ -506,7 +522,7 @@ function paint(api) { mask({ x: 0, y: listTop, width: screen.width, height: listH }); let ly = listTop - scroll; - measured.forEach(({ letter, body, rowH }, i) => { + measured.forEach(({ letter, body, rowH, mediaItems, textH }, i) => { if (ly + rowH >= listTop && ly < listTop + listH) { const unread = view === "inbox" && !letter.read; const who = (view === "inbox" ? letter.from : letter.to) || "someone"; @@ -548,6 +564,15 @@ function paint(api) { } ink([...c.timestamp, 160]).write(ago(letter.when), { x: screen.width - x - agoW + 4, y: yy }, undefined, undefined, false, face); ink(unread ? c.messageText : [...c.messageText, 190]).write(body, { x: x + 10, y: yy + lh }, undefined, bounds, true, face); + let mediaY = yy + lh + textH + 2; + for (const item of mediaItems) { + if (mediaY + item.height > listTop && mediaY < screen.height - 4) { + mediaView.paint(api, item, x + 10, mediaY, bounds, c.painting, s); + mediaHits.push({ x: x + 10, y: Math.max(listTop, mediaY), w: bounds, + h: Math.min(screen.height - 4, mediaY + item.height) - Math.max(listTop, mediaY), item }); + } + mediaY += item.height; + } } ly += rowH; }); @@ -761,6 +786,18 @@ function act(api) { // Tap a letter to answer it — the field opens already addressed. A drag // that ended on a letter was a scroll, not a tap. if (e.is("lift") && !dragged && listing) { + const media = mediaHits.find((box) => hit(box)); + if (media) { + if (!downloading) { + downloading = true; + mediaNote = media.item.file ? S().downloading : null; + mediaView.open(api, media.item).then(() => { mediaNote = null; }) + .catch(() => { mediaNote = S().downloadFailed; }) + .finally(() => { downloading = false; needsPaint(); }); + } + needsPaint(); + return; + } const row = rows.find((r) => e.y >= r.y0 && e.y < r.y1); const address = row?.who?.startsWith("@") ? row.who : row?.email; if (address && e.y >= listTop) { @@ -770,4 +807,6 @@ function act(api) { } } -export { meta, boot, sim, paint, act }; +function leave() { mediaView.clear(); } + +export { meta, boot, sim, paint, act, leave };