inbox-send.ts
Language TypeScript35.9 KB912 lines
/*** The outbound routes: decide, confirm, send, record.** Three kinds of send, and the difference between them is where the recipient* comes from, which is the central question of this file:** reply one recipient, read off the original on the server* reply-all that recipient plus the original's To and Cc, also off the* original on the server, minus us* new recipients from the form, because there is no original** The first two keep a strong guarantee: a person can pick which message to* answer and which mailbox to answer as, and nothing else* they submit can steer where mail goes. The third cannot keep it (a compose* box with no To field is not a compose box), so it gets the guarantee's* replacement: every address is parsed and re-emitted from the parse* (`parseRecipients`), the count is capped, and the confirmation page shows the* exact list before anything is sent. Different mechanism, same job.** A separate file from index.ts because the Worker's own tests can only* exercise identity-free routes or pure functions, because everything else is* behind a Cloudflare Access JWT they cannot mint. Handlers that take their* collaborators as arguments can be tested against a fake KV and a fake send* without a network, an Access token, or a real mailbox. The one irreversible* thing this board does is the last place to accept "covered by the deploy".** Runtime-neutral: no `node:` imports (rule 7).*/import {replySubject,type GmailCredential,type MessageBody,type OutgoingAttachment,type OutgoingMessage,type SendResult,} from '@meridian/gmail-client'import { normalizeAddress, parseFrom, splitAddressList } from '@meridian/gmail-client'import {SIGNATURE_CAP,type RepliedMark,type SendRecord,type SentAttachment,type Signature,type SignatureStore,} from '../../dashboard/src/inbox-view.js'import { ATTACHMENT_LIMITS, RECIPIENT_CAP, canSendFrom } from '../../../config/inbox.js'import type { Viewer } from '../../dashboard/src/viewer.js'import { errorMessage } from '@meridian/shared'/** Only the bits of the Worker Env this module touches. */export interface SendEnv {DASHBOARD: {get(key: string): Promise<string | null>put(key: string, value: string, opts?: { metadata?: unknown }): Promise<void>delete(key: string): Promise<void>list(opts: { prefix: string; limit?: number; cursor?: string }): Promise<{keys: { name: string; metadata?: unknown }[]list_complete: booleancursor?: string}>}}export const SEND_KEY = {signatures: 'inbox:signatures',sentPrefix: 'inbox:sent:',replied: 'inbox:replied',} as const/*** The signature store.** A malformed value is not swallowed, unlike readNotes and readReadMarks. Their* safe fallback is emptiness because a lost note is a missing annotation and a* lost read mark falls back to Gmail's own state. A signature that vanished* silently would change an email that has already gone to a client, and the* person who sent it would have no way to know. So a parse failure comes back* as a failure and every surface says so.** A missing key is `ok` with an empty map, because "nobody has set one yet" is* the true and normal state before the first save, and it is also the safe* degradation if the key is ever lost: replies go out unsigned and the page* says "not set" for every mailbox.*/export async function readSignatures(env: SendEnv): Promise<SignatureStore> {const raw = await env.DASHBOARD.get(SEND_KEY.signatures)if (!raw) return { ok: true, map: {} }try {const parsed = JSON.parse(raw) as unknownif (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return { ok: false, error: 'inbox:signatures holds something that is not an object' }}const map: Record<string, Signature> = {}for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {const s = v as Partial<Signature>if (typeof s?.html === 'string' && typeof s.by === 'string' && typeof s.at === 'string') {map[k] = { html: s.html, by: s.by, at: s.at }}}return { ok: true, map }} catch (err) {return { ok: false, error: errorMessage(err) }}}/*** The persistent replied marks.** Empty on a lost or malformed key: the board then shows no replied chips,* which is not a lie about anything. The reply itself* is still in Gmail's Sent folder and still in `inbox:sent:*`.*/export async function readReplied(env: SendEnv): Promise<Record<string, RepliedMark>> {const raw = await env.DASHBOARD.get(SEND_KEY.replied)if (!raw) return {}try {const parsed = JSON.parse(raw) as unknownif (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}const out: Record<string, RepliedMark> = {}for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {const m = v as Partial<RepliedMark>if (typeof m?.at === 'number' && typeof m.by === 'string' && typeof m.from === 'string') {out[k] = { at: m.at, by: m.by, from: m.from }}}return out} catch {return {}}}/*** Marks age out at 35 days, past the 30-day list window, so a mark can never* disappear from under a message still on the page, and the key cannot grow* without bound. Same constant and same reasoning as the read marks.*/export const REPLIED_MARK_MAX_AGE_MS = 35 * 24 * 60 * 60 * 1000export async function markReplied(env: SendEnv,key: string,mark: RepliedMark,now: number,): Promise<void> {const prior = await readReplied(env)const next: Record<string, RepliedMark> = {}for (const [k, v] of Object.entries(prior)) {if (k !== key && now - v.at < REPLIED_MARK_MAX_AGE_MS) next[k] = v}next[key] = markawait env.DASHBOARD.put(SEND_KEY.replied, JSON.stringify(next))}/*** Does this look like an address we can hand to Gmail?** Deliberately not an RFC 5322 validator: that grammar admits quoted local* parts and bare hostnames, and neither belongs in a box a person types a* client's email into. One `@`, no whitespace, a dot in the domain. Anything* stricter starts rejecting real addresses; anything looser starts accepting* things that are not addresses at all.*/const looksLikeAddress = (a: string): boolean => /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]{2,}$/.test(a)export type Recipients =| { ok: true; header: string; addresses: string[] }| { ok: false; error: string }/*** Parse an address list and re-emit it from the parse.** The re-emission is the security property, not the validation. What goes into* the header is built out of strings that each matched `looksLikeAddress`, so* a CR, an LF, a `,` or a `"` cannot reach a header from here whatever anyone* types; there is no path by which unparsed input is copied through. That is* what replaces "the recipient never comes from the form" for the compose box,* and it is why the address is kept and the display name is not: `Lena Ortiz* <lena@x>` goes out as `lena@x`. The name is cosmetic and it is the half that* can contain anything.** Duplicates collapse on the normalized address (so `Lena@X.com` and* `lena@x.com` are one person, and one Cc line), while the original spelling* is what gets sent. normalizeAddress strips `+tags`, which is right for* grouping senders in the list view and wrong for addressing mail.** Over the cap is refused with the count. See RECIPIENT_CAP.*/export function parseRecipients(raw: string, what = 'recipient'): Recipients {const entries = splitAddressList(raw)const addresses: string[] = []const seen = new Set<string>()for (const entry of entries) {const { sender } = parseFrom(entry)// parseFrom lowercases and strips +tags; for addressing we want what was// typed, so the angle-bracket content is taken verbatim and only used to// key the duplicate check through normalizeAddress.const m = /<([^>]+)>\s*$/.exec(entry.trim())const literal = (m ? m[1]! : entry).trim()if (!looksLikeAddress(literal)) {return { ok: false, error: `"${entry}" is not an address this board will send to. Check the ${what} list.` }}const key = normalizeAddress(sender || literal)if (seen.has(key)) continueseen.add(key)addresses.push(literal)}if (!addresses.length) return { ok: false, error: `No ${what} was given, so nothing was sent.` }if (addresses.length > RECIPIENT_CAP) {return {ok: false,error: `That is ${addresses.length} ${what}s, over this board's ceiling of ${RECIPIENT_CAP}. Nothing was sent. Send it from Gmail, or cut the list.`,}}return { ok: true, header: addresses.join(', '), addresses }}/*** Everyone on the original except us: the reply-all Cc, derived on the server.** The list is built from the fetched original's To and Cc (never from the* form, which carries only the boolean), so reply-all keeps the property plain* reply has.** What comes out, and why each exclusion:* the primary recipient already in To; a Cc copy of it is a second copy* the sending mailbox Cc'ing yourself puts your own reply in your inbox* the mailbox it arrived in same, when replying as a different mailbox* Everything else stays, including other Meridian addresses: `help@` sitting on* a client thread is a colleague who is following it, and dropping them is* exactly the silent omission this board must not make.*/export function replyAllCc(orig: MessageBody, from: string, to: string): Recipients | null {const raw = [orig.to, orig.cc].filter(Boolean).join(', ')if (!raw.trim()) return nullconst drop = new Set([to, from, orig.mailbox].filter(Boolean).map((a) => normalizeAddress(parseFrom(a).sender || a)),)const kept: string[] = []for (const entry of splitAddressList(raw)) {const m = /<([^>]+)>\s*$/.exec(entry.trim())const literal = (m ? m[1]! : entry).trim()if (!looksLikeAddress(literal)) continue // a malformed entry on someone else's header is not ours to fail onif (drop.has(normalizeAddress(parseFrom(entry).sender || literal))) continuekept.push(literal)}if (!kept.length) return nullreturn parseRecipients(kept.join(', '), 'Cc')}/*** Turn the original message plus a choice of mailbox into the exact thing that* will go out. The only place a reply's recipient is decided.** Everything here reads from `orig`, which came from Gmail on this request, and* nothing reads from the form. The form carries which message, which mailbox to* send as, the typed text, whether to include everyone, and a nonce: nothing* that steers where mail goes.** `replyAll` failing is not silent. When the derived Cc is over the recipient* cap this returns the error rather than a message with a trimmed Cc: a* reply-all that quietly drops people is the failure mode reply-all has.*/export function decideReply(orig: MessageBody,from: string,text: string,signatureHtml: string | null,opts: { replyAll?: boolean; attachments?: OutgoingAttachment[] } = {},): OutgoingMessage | { error: string } {// Reply-To wins over From when it parses to something with an @. A sender// that asked for replies elsewhere gets them there; a malformed Reply-To// falls back rather than producing a reply addressed to nothing.const replyToAddr = orig.replyTo ? parseFrom(orig.replyTo).sender : ''const to = replyToAddr.includes('@') ? replyToAddr : orig.senderconst refs = [orig.references, orig.messageId].filter((x): x is string => Boolean(x))let cc: string | null = nullif (opts.replyAll) {const derived = replyAllCc(orig, from, to)if (derived && !derived.ok) return { error: derived.error }cc = derived?.ok ? derived.header : null}return {from,to,cc,subject: replySubject(orig.subject),text,signatureHtml,inReplyTo: orig.messageId,references: refs.length ? refs.join(' ') : null,// Gmail thread ids are per-mailbox. Passing the id a message has in hello@// through accounts@'s token is rejected outright, so it is sent only when// replying from the mailbox the message arrived in. The recipient's client// threads on In-Reply-To/References either way; what differs is whether the// sending mailbox's own Sent folder shows it in the same thread.threadId: from === orig.mailbox && orig.threadId ? orig.threadId : null,attachments: opts.attachments ?? [],// Nothing identifies the person behind a shared mailbox. Attribution is// wanted only if it is truly invisible to the client, and an X- header is// not: "Show original" displays every header.// So the outgoing message carries none, and who sent it lives only in// inbox:sent:* and in the mailbox's own Sent folder.}}/*** A message with no original: the one place recipients come from a form.** Every other send on this board answers something a client sent us, which is* what made "the recipient is never in the request" possible. A compose box* cannot work that way, so this function is the boundary where that stops* being true, and it is written as its own function (rather than a branch* inside decideReply) so that the boundary has a name and a place to hold* this comment.** No threading fields at all: no In-Reply-To, no References, no thread id.* A new message that claimed a thread would land inside an unrelated* conversation in the recipient's client.*/export function decideNew(input: {from: stringto: stringcc: string | nullsubject: stringtext: stringsignatureHtml: string | nullattachments: OutgoingAttachment[]}): OutgoingMessage {return {from: input.from,to: input.to,cc: input.cc,// An empty subject is legal and Gmail sends it; it is labelled rather than// invented, the same way the message list labels one.subject: input.subject.trim() || '(no subject)',text: input.text,signatureHtml: input.signatureHtml,inReplyTo: null,references: null,threadId: null,attachments: input.attachments,}}/*** A file on its way out, staged as text so it can cross the confirmation step.** Base64 in a hidden field rather than KV. The compose page uploads, the* confirmation page shows what will go, and the send happens on a third* request, so the bytes have to survive two hops. KV is the obvious store and* is wrong for three reasons, in order of weight:** KV is eventually consistent (up to about a minute), so the confirm* or the send could read back nothing for a draft that was definitely* written, and the failure would land on the one action that cannot be* retried safely.** It would put client files at rest in the board's own storage, with a TTL* to get wrong. body.ts already argues this for message bodies: on demand* means the board never accumulates seven people's mail.** It would be more board-owned state (rule 12) for something that is in* flight for ninety seconds.** The cost is that the browser re-posts the files once, and that the caps in* config/inbox.ts are what keep that re-post small. Both are stated on the* page.*/export interface StagedAttachment {name: stringmimeType: string/** Standard base64 of the file's bytes. */b64: string}/** Bytes -> base64, chunked past the argument limit (a 3 MB spread throws). */const b64FromBytes = (bytes: Uint8Array): string => {let bin = ''for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode(...bytes.subarray(i, i + 8192))return btoa(bin)}/** base64 -> bytes. Same decoder the download route uses, via the client. */const bytesFromB64 = (b64: string): Uint8Array => {const bin = atob(b64)const out = new Uint8Array(bin.length)for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)return out}/*** How big the staged form is allowed to be, in base64 characters.** Derived from the byte cap rather than typed twice: 4/3 of the bytes, plus a* kilobyte of JSON scaffolding per file. A hand-built post that skips the* upload step and puts a 40 MB string straight in the hidden field is refused* by this before anything is decoded; the decode is where the memory goes.*/const STAGED_CAP = Math.ceil((ATTACHMENT_LIMITS.total * 4) / 3) + ATTACHMENT_LIMITS.count * 1024/** Bytes, as a person reads them. Not KB when the cap is quoted in MB. */const mb = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MB`export type Uploads = { ok: true; files: StagedAttachment[] } | { ok: false; error: string }/*** The shape of an uploaded file, duck-typed rather than `File`.** The Worker's own FormData types entries as strings, so the signature-upload* handler already reaches for a cast here; this is the same move with the* fields an attachment needs. Checking for the methods rather than the class* is also what makes `readUploads` testable without a DOM File.*/type UploadLike = {name: stringsize: numbertype?: stringarrayBuffer: () => Promise<ArrayBuffer>}/*** The files off a multipart compose form, checked against the caps.** An empty file input submits a file. Browsers post a zero-byte entry with an* empty name for `<input type="file">` when nothing was chosen, so those are* dropped here. Without that, every reply would carry one nameless empty* attachment and the count check would fail on the fifth real file.** Over any cap refuses the whole submission and names the file and the size.* Nothing is trimmed to fit: a message that went out believing it carried a* file it did not is worse than a message that was refused.*/export async function readUploads(form: FormData): Promise<Uploads> {const raw = (form.getAll('files') as unknown[]).filter((v): v is UploadLike =>typeof v === 'object' && v !== null && 'arrayBuffer' in v && 'size' in v && 'name' in v,)const picked = raw.filter((f) => f.size > 0 || f.name.trim() !== '')if (!picked.length) return { ok: true, files: [] }if (picked.length > ATTACHMENT_LIMITS.count) {return {ok: false,error: `${picked.length} files were attached; this board sends at most ${ATTACHMENT_LIMITS.count} on one message. Nothing was sent.`,}}let total = 0const files: StagedAttachment[] = []for (const f of picked) {if (f.size > ATTACHMENT_LIMITS.perFile) {return {ok: false,error: `"${f.name}" is ${mb(f.size)}, over the ${mb(ATTACHMENT_LIMITS.perFile)} per-file limit. Nothing was sent. Send that one from Gmail.`,}}total += f.sizeif (total > ATTACHMENT_LIMITS.total) {return {ok: false,error: `Those files come to ${mb(total)}, over the ${mb(ATTACHMENT_LIMITS.total)} total. Nothing was sent.`,}}files.push({name: f.name,mimeType: f.type || 'application/octet-stream',b64: b64FromBytes(new Uint8Array(await f.arrayBuffer())),})}return { ok: true, files }}/*** Read the staged files back off the confirmation form.** Re-checked, not trusted. The compose step already applied the caps, and this* applies them again to the decoded bytes, because between the two steps the* payload was in a form field on somebody's browser. The confirmation page is* a page like any other: what it hands back is input.*/export function parseStaged(raw: string): Uploads {if (!raw.trim()) return { ok: true, files: [] }if (raw.length > STAGED_CAP) {return { ok: false, error: 'The attached files were larger than this board accepts. Nothing was sent.' }}let parsed: unknowntry {parsed = JSON.parse(raw)} catch {return { ok: false, error: 'The attached files did not survive the confirmation step, so nothing was sent. Attach them again.' }}if (!Array.isArray(parsed)) {return { ok: false, error: 'The attached files did not survive the confirmation step, so nothing was sent. Attach them again.' }}if (parsed.length > ATTACHMENT_LIMITS.count) {return { ok: false, error: `More than ${ATTACHMENT_LIMITS.count} files were submitted. Nothing was sent.` }}const files: StagedAttachment[] = []for (const v of parsed) {const f = v as Partial<StagedAttachment>if (typeof f?.name !== 'string' || typeof f.mimeType !== 'string' || typeof f.b64 !== 'string') {return { ok: false, error: 'One of the attached files arrived malformed, so nothing was sent.' }}files.push({ name: f.name, mimeType: f.mimeType, b64: f.b64 })}return { ok: true, files }}/** Staged text -> the bytes the encoder wants, with the size caps re-applied. */export function stagedToOutgoing(files: StagedAttachment[]): { ok: true; files: OutgoingAttachment[] } | { ok: false; error: string } {let total = 0const out: OutgoingAttachment[] = []for (const f of files) {let bytes: Uint8Arraytry {bytes = bytesFromB64(f.b64)} catch {return { ok: false, error: `"${f.name}" could not be decoded, so nothing was sent.` }}if (bytes.byteLength > ATTACHMENT_LIMITS.perFile) {return { ok: false, error: `"${f.name}" is ${mb(bytes.byteLength)}, over the ${mb(ATTACHMENT_LIMITS.perFile)} per-file limit. Nothing was sent.` }}total += bytes.byteLengthif (total > ATTACHMENT_LIMITS.total) {return { ok: false, error: `The attached files come to ${mb(total)}, over the ${mb(ATTACHMENT_LIMITS.total)} total. Nothing was sent.` }}out.push({ name: f.name, mimeType: f.mimeType, bytes })}return { ok: true, files: out }}/** What the audit row records about the files. Names and sizes, never bytes. */export const sentAttachments = (files: OutgoingAttachment[]): SentAttachment[] =>files.map((f) => ({ name: f.name, size: f.bytes.byteLength }))export interface SendDeps {fetchBody: (mailbox: string, id: string, cred: GmailCredential) => Promise<MessageBody | { error: string }>sendMessage: (mailbox: string, cred: GmailCredential, msg: OutgoingMessage) => Promise<SendResult>credentialFor: (mailbox: string) => GmailCredential | { error: string }now: () => number}/** A form value, trimmed. Never `undefined` reaching a comparison. */const field = (form: FormData, name: string): string => String(form.get(name) ?? '').trim()export type SendMode = 'reply' | 'new'/** 'new' only when the form says so. An unknown value is a reply, never a new* message: the reply path is the one that cannot address a stranger. */const modeOf = (form: FormData): SendMode => (String(form.get('mode') ?? '') === 'new' ? 'new' : 'reply')export type SendOutcome =| { kind: 'denied' }| { kind: 'redirect'; location: string }| { kind: 'error'; message: string }| {kind: 'confirm'mode: SendMode/** The message being answered. Null for a new message, which has none. */orig: MessageBody | nulloutgoing: OutgoingMessagenonce: string/** Carried through the confirmation page in a hidden field. */staged: StagedAttachment[]}/*** The recipient fields of a new message, validated together.** The two lists are capped as a sum, not one each: fifteen in To and fifteen* in Cc is thirty people, which is the thing the cap exists to prevent.*/function newRecipients(form: FormData): { to: string; cc: string | null } | { error: string } {const to = parseRecipients(String(form.get('to') ?? ''), 'recipient')if (!to.ok) return { error: to.error }const rawCc = String(form.get('cc') ?? '').trim()if (!rawCc) return { to: to.header, cc: null }const cc = parseRecipients(rawCc, 'Cc')if (!cc.ok) return { error: cc.error }if (to.addresses.length + cc.addresses.length > RECIPIENT_CAP) {return {error: `That is ${to.addresses.length + cc.addresses.length} recipients across To and Cc, over this board's ceiling of ${RECIPIENT_CAP}. Nothing was sent.`,}}return { to: to.header, cc: cc.header }}/*** The confirmation step. Nothing is sent and nothing is written.** It re-fetches the original rather than trusting anything the compose form* carried, so the addresses shown on the confirmation page are the addresses* the send will actually use; a preview computed from different inputs than* the action is a preview of nothing.*/export async function handleCompose(viewer: Viewer,env: SendEnv,form: FormData,readable: string[],deps: SendDeps,): Promise<SendOutcome> {const mode = modeOf(form)const from = field(form, 'from').toLowerCase()const text = String(form.get('body') ?? '')const nonce = field(form, 'nonce')// Sending as `from` needs the send grant, whatever the mode. Reading an// original needs the read grant as well, and that check is inside the reply// branch: two checks because they are two different permissions, which is// the whole reason INBOX_SENDERS exists as its own list.if (!from || !canSendFrom(viewer, from)) return { kind: 'denied' }if (!nonce) return { kind: 'error', message: 'That form was submitted without its one-time token, so nothing was done. Open the message and press Reply again.' }if (!text.trim()) return { kind: 'error', message: 'The message was empty, so nothing was sent.' }const uploads = await readUploads(form)if (!uploads.ok) return { kind: 'error', message: uploads.error }const sigs = await readSignatures(env)const signatureHtml = sigs.ok ? (sigs.map[from]?.html ?? null) : null// The bytes are decoded here only to be re-encoded by the sender later. It// is the one place both forms exist at once, and it is what lets the// confirmation page show a real size rather than a claimed one.const decoded = stagedToOutgoing(uploads.files)if (!decoded.ok) return { kind: 'error', message: decoded.error }if (mode === 'new') {const rcpt = newRecipients(form)if ('error' in rcpt) return { kind: 'error', message: rcpt.error }return {kind: 'confirm',mode,orig: null,outgoing: decideNew({from,to: rcpt.to,cc: rcpt.cc,subject: field(form, 'subject'),text,signatureHtml,attachments: decoded.files,}),nonce,staged: uploads.files,}}const mailbox = field(form, 'mailbox').toLowerCase()const id = field(form, 'id')if (!mailbox || !id || !readable.includes(mailbox)) return { kind: 'denied' }const cred = deps.credentialFor(mailbox)if ('error' in cred) return { kind: 'error', message: cred.error }const orig = await deps.fetchBody(mailbox, id, cred)if ('error' in orig) return { kind: 'error', message: orig.error }const outgoing = decideReply(orig, from, text, signatureHtml, {replyAll: field(form, 'all') === '1',attachments: decoded.files,})if ('error' in outgoing) return { kind: 'error', message: outgoing.error }return { kind: 'confirm', mode, orig, outgoing, nonce, staged: uploads.files }}/*** The send. The one outbound write.** The order is the point:* 1. claim the nonce: a record already there means this was submitted before* 2. write 'sending': before Gmail is called, so a Worker torn down* mid-flight leaves evidence rather than silence* 3. call Gmail* 4. overwrite with the outcome, and on success set the persistent replied mark** The residual is honest and stated on the page: KV is eventually consistent* across edge locations (up to about a minute), so two posts of the same nonce* from different locations inside that window could both pass. A back-button* resubmit from one browser hits one location and is caught.*/export async function handleSend(viewer: Viewer,env: SendEnv,form: FormData,readable: string[],deps: SendDeps,): Promise<SendOutcome> {const mode = modeOf(form)const from = field(form, 'from').toLowerCase()const text = String(form.get('body') ?? '')const nonce = field(form, 'nonce')if (!from || !canSendFrom(viewer, from)) return { kind: 'denied' }if (!nonce || !/^[A-Za-z0-9-]{8,64}$/.test(nonce)) {return { kind: 'error', message: 'That form was submitted without a usable one-time token, so nothing was sent.' }}if (!text.trim()) return { kind: 'error', message: 'The message was empty, so nothing was sent.' }// The staged files are re-validated, and before the nonce is claimed: a// submission that fails the caps must be re-submittable, so it must not burn// the one-time token on its way to being refused.const staged = parseStaged(String(form.get('staged') ?? ''))if (!staged.ok) return { kind: 'error', message: staged.error }const decoded = stagedToOutgoing(staged.files)if (!decoded.ok) return { kind: 'error', message: decoded.error }const mailbox = field(form, 'mailbox').toLowerCase()const id = field(form, 'id')if (mode === 'reply' && (!mailbox || !id || !readable.includes(mailbox))) return { kind: 'denied' }const key = `${SEND_KEY.sentPrefix}${nonce}`const claimed = await env.DASHBOARD.get(key)if (claimed) {return { kind: 'redirect', location: `/inbox/sent?ref=${encodeURIComponent(nonce)}&dup=1` }}const cred = deps.credentialFor(from)if ('error' in cred) return { kind: 'error', message: cred.error }const sigs = await readSignatures(env)const signatureHtml = sigs.ok ? (sigs.map[from]?.html ?? null) : nulllet outgoing: OutgoingMessagelet orig: MessageBody | null = nullif (mode === 'new') {const rcpt = newRecipients(form)if ('error' in rcpt) return { kind: 'error', message: rcpt.error }outgoing = decideNew({from,to: rcpt.to,cc: rcpt.cc,subject: field(form, 'subject'),text,signatureHtml,attachments: decoded.files,})} else {const origCred = deps.credentialFor(mailbox)if ('error' in origCred) return { kind: 'error', message: origCred.error }const fetched = await deps.fetchBody(mailbox, id, origCred)if ('error' in fetched) return { kind: 'error', message: fetched.error }orig = fetched// Re-derived from the original, not carried from the confirmation page.// The preview and the send are computed from the same inputs by the same// function, which is the only way the preview is a preview of anything.const decided = decideReply(fetched, from, text, signatureHtml, {replyAll: field(form, 'all') === '1',attachments: decoded.files,})if ('error' in decided) return { kind: 'error', message: decided.error }outgoing = decided}const at = new Date(deps.now()).toISOString()const record: SendRecord = {nonce,at,by: viewer.person?.name ?? viewer.email ?? 'unknown',byEmail: viewer.email ?? '',from,to: outgoing.to,cc: outgoing.cc,subject: outgoing.subject,mode,// Null, not '', for a new message. There is no original, and an empty// string in a field that holds a mailbox address would render as one that// could not be read (the prime invariant, applied to the audit row).originalMailbox: mode === 'reply' ? mailbox : null,originalId: mode === 'reply' ? id : null,originalMessageId: orig?.messageId ?? null,threadIdSent: outgoing.threadId,signatureApplied: signatureHtml !== null,attachments: sentAttachments(decoded.files),outcome: 'sending',gmailId: null,gmailThreadId: null,error: null,candidates: [],}await writeRecord(env, record)const result = await deps.sendMessage(from, cred, outgoing)const final: SendRecord = result.ok? { ...record, outcome: 'sent', gmailId: result.id, gmailThreadId: result.threadId }: { ...record, outcome: 'failed', error: result.error, candidates: result.candidates }await writeRecord(env, final)if (result.ok && mode === 'reply') {// The mark goes on after Gmail accepts, and only then. A "replied" chip on// a message nobody answered is worse than no chip at all in a shared// mailbox; it is what makes someone else not answer it.await markReplied(env, `${mailbox}/${id}`, { at: deps.now(), by: record.by, from }, deps.now())}return { kind: 'redirect', location: `/inbox/sent?ref=${encodeURIComponent(nonce)}` }}/*** One KV key per attempt, not one array everyone appends to: two people* replying at the same moment would each read the array, add their row, and* write it back, and one of them would lose their record entirely.** The metadata carries a trimmed copy so the listing can render without reading* any values.*/async function writeRecord(env: SendEnv, r: SendRecord): Promise<void> {await env.DASHBOARD.put(`${SEND_KEY.sentPrefix}${r.nonce}`, JSON.stringify(r), {// KV caps metadata at 1,024 bytes, hence the subject cut, and hence the// attachments being reduced to a count here rather than a list of names.// Five long filenames would push the row over the cap, and a metadata// write that fails takes the whole listing row with it.metadata: {...r,subject: r.subject.slice(0, 120),candidates: [],attachments: null,attachmentCount: r.attachments?.length ?? null,cc: r.cc ? r.cc.slice(0, 120) : r.cc,},})}export async function readRecord(env: SendEnv, nonce: string): Promise<SendRecord | null> {const raw = await env.DASHBOARD.get(`${SEND_KEY.sentPrefix}${nonce}`)if (!raw) return nulltry {return JSON.parse(raw) as SendRecord} catch {return null}}/*** The listing. Capped and labelled capped (rule 5): a truncated list that* claims to be complete is the failure mode; throwing away rows to avoid one is* the other.*/export async function listSent(env: SendEnv,limit = 200,): Promise<{ rows: SendRecord[]; truncated: boolean }> {const res = await env.DASHBOARD.list({ prefix: SEND_KEY.sentPrefix, limit })const rows = res.keys.map((k) => k.metadata as SendRecord | undefined).filter((r): r is SendRecord => Boolean(r && r.at && r.from)).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))return { rows, truncated: !res.list_complete }}export type SignatureOutcome =| { kind: 'denied' }| { kind: 'redirect'; location: string }/*** Set or clear one mailbox's signature.** `canSendFrom`, never `readableMailboxes`: the whole roster may read every* mailbox, and a signature is what goes out over that mailbox's name.** Over the cap writes nothing. Not a truncated signature: a signature cut at* 20,000 characters would go out as broken HTML on every reply, and the person* who pasted it would have no reason to look.*/export async function handleSignaturesPost(viewer: Viewer,env: SendEnv,form: FormData,now: () => number,): Promise<SignatureOutcome> {const mailbox = field(form, 'mailbox').toLowerCase()const op = field(form, 'op')if (!mailbox || !canSendFrom(viewer, mailbox)) return { kind: 'denied' }if (op !== 'set' && op !== 'clear') return { kind: 'redirect', location: '/inbox/signatures' }const store = await readSignatures(env)// A store that could not be read must not be overwritten: doing so would turn// a transient read failure into the permanent loss of everyone's signature.if (!store.ok) return { kind: 'redirect', location: '/inbox/signatures' }const next = { ...store.map }if (op === 'clear') {delete next[mailbox]await env.DASHBOARD.put(SEND_KEY.signatures, JSON.stringify(next))return { kind: 'redirect', location: '/inbox/signatures' }}const uploaded = form.get('file')const fromFile =uploaded && typeof uploaded === 'object' && 'text' in uploaded? await (uploaded as unknown as { text: () => Promise<string> }).text(): ''const html = (fromFile.trim() ? fromFile : String(form.get('html') ?? '')).trim()if (html.length > SIGNATURE_CAP) {return {kind: 'redirect',location: `/inbox/signatures?rejected=${encodeURIComponent(mailbox)}&over=${html.length}`,}}if (!html) {// Empty after trim is a clear; the control that writes can unwrite, as// with the board's notes.delete next[mailbox]} else {next[mailbox] = { html, by: viewer.person?.name ?? viewer.email ?? 'unknown', at: new Date(now()).toISOString() }}await env.DASHBOARD.put(SEND_KEY.signatures, JSON.stringify(next))return { kind: 'redirect', location: '/inbox/signatures' }}