mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

inbox-send.ts

Language TypeScript35.9 KB912 lines

apps/worker/src/inbox-send.ts

  1. /**
  2. * The outbound routes: decide, confirm, send, record.
  3. *
  4. * Three kinds of send, and the difference between them is where the recipient
  5. * comes from, which is the central question of this file:
  6. *
  7. * reply one recipient, read off the original on the server
  8. * reply-all that recipient plus the original's To and Cc, also off the
  9. * original on the server, minus us
  10. * new recipients from the form, because there is no original
  11. *
  12. * The first two keep a strong guarantee: a person can pick which message to
  13. * answer and which mailbox to answer as, and nothing else
  14. * they submit can steer where mail goes. The third cannot keep it (a compose
  15. * box with no To field is not a compose box), so it gets the guarantee's
  16. * replacement: every address is parsed and re-emitted from the parse
  17. * (`parseRecipients`), the count is capped, and the confirmation page shows the
  18. * exact list before anything is sent. Different mechanism, same job.
  19. *
  20. * A separate file from index.ts because the Worker's own tests can only
  21. * exercise identity-free routes or pure functions, because everything else is
  22. * behind a Cloudflare Access JWT they cannot mint. Handlers that take their
  23. * collaborators as arguments can be tested against a fake KV and a fake send
  24. * without a network, an Access token, or a real mailbox. The one irreversible
  25. * thing this board does is the last place to accept "covered by the deploy".
  26. *
  27. * Runtime-neutral: no `node:` imports (rule 7).
  28. */
  29. import {
  30. replySubject,
  31. type GmailCredential,
  32. type MessageBody,
  33. type OutgoingAttachment,
  34. type OutgoingMessage,
  35. type SendResult,
  36. } from '@meridian/gmail-client'
  37. import { normalizeAddress, parseFrom, splitAddressList } from '@meridian/gmail-client'
  38. import {
  39. SIGNATURE_CAP,
  40. type RepliedMark,
  41. type SendRecord,
  42. type SentAttachment,
  43. type Signature,
  44. type SignatureStore,
  45. } from '../../dashboard/src/inbox-view.js'
  46. import { ATTACHMENT_LIMITS, RECIPIENT_CAP, canSendFrom } from '../../../config/inbox.js'
  47. import type { Viewer } from '../../dashboard/src/viewer.js'
  48. import { errorMessage } from '@meridian/shared'
  49. /** Only the bits of the Worker Env this module touches. */
  50. export interface SendEnv {
  51. DASHBOARD: {
  52. get(key: string): Promise<string | null>
  53. put(key: string, value: string, opts?: { metadata?: unknown }): Promise<void>
  54. delete(key: string): Promise<void>
  55. list(opts: { prefix: string; limit?: number; cursor?: string }): Promise<{
  56. keys: { name: string; metadata?: unknown }[]
  57. list_complete: boolean
  58. cursor?: string
  59. }>
  60. }
  61. }
  62. export const SEND_KEY = {
  63. signatures: 'inbox:signatures',
  64. sentPrefix: 'inbox:sent:',
  65. replied: 'inbox:replied',
  66. } as const
  67. /**
  68. * The signature store.
  69. *
  70. * A malformed value is not swallowed, unlike readNotes and readReadMarks. Their
  71. * safe fallback is emptiness because a lost note is a missing annotation and a
  72. * lost read mark falls back to Gmail's own state. A signature that vanished
  73. * silently would change an email that has already gone to a client, and the
  74. * person who sent it would have no way to know. So a parse failure comes back
  75. * as a failure and every surface says so.
  76. *
  77. * A missing key is `ok` with an empty map, because "nobody has set one yet" is
  78. * the true and normal state before the first save, and it is also the safe
  79. * degradation if the key is ever lost: replies go out unsigned and the page
  80. * says "not set" for every mailbox.
  81. */
  82. export async function readSignatures(env: SendEnv): Promise<SignatureStore> {
  83. const raw = await env.DASHBOARD.get(SEND_KEY.signatures)
  84. if (!raw) return { ok: true, map: {} }
  85. try {
  86. const parsed = JSON.parse(raw) as unknown
  87. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
  88. return { ok: false, error: 'inbox:signatures holds something that is not an object' }
  89. }
  90. const map: Record<string, Signature> = {}
  91. for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
  92. const s = v as Partial<Signature>
  93. if (typeof s?.html === 'string' && typeof s.by === 'string' && typeof s.at === 'string') {
  94. map[k] = { html: s.html, by: s.by, at: s.at }
  95. }
  96. }
  97. return { ok: true, map }
  98. } catch (err) {
  99. return { ok: false, error: errorMessage(err) }
  100. }
  101. }
  102. /**
  103. * The persistent replied marks.
  104. *
  105. * Empty on a lost or malformed key: the board then shows no replied chips,
  106. * which is not a lie about anything. The reply itself
  107. * is still in Gmail's Sent folder and still in `inbox:sent:*`.
  108. */
  109. export async function readReplied(env: SendEnv): Promise<Record<string, RepliedMark>> {
  110. const raw = await env.DASHBOARD.get(SEND_KEY.replied)
  111. if (!raw) return {}
  112. try {
  113. const parsed = JSON.parse(raw) as unknown
  114. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
  115. const out: Record<string, RepliedMark> = {}
  116. for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
  117. const m = v as Partial<RepliedMark>
  118. if (typeof m?.at === 'number' && typeof m.by === 'string' && typeof m.from === 'string') {
  119. out[k] = { at: m.at, by: m.by, from: m.from }
  120. }
  121. }
  122. return out
  123. } catch {
  124. return {}
  125. }
  126. }
  127. /**
  128. * Marks age out at 35 days, past the 30-day list window, so a mark can never
  129. * disappear from under a message still on the page, and the key cannot grow
  130. * without bound. Same constant and same reasoning as the read marks.
  131. */
  132. export const REPLIED_MARK_MAX_AGE_MS = 35 * 24 * 60 * 60 * 1000
  133. export async function markReplied(
  134. env: SendEnv,
  135. key: string,
  136. mark: RepliedMark,
  137. now: number,
  138. ): Promise<void> {
  139. const prior = await readReplied(env)
  140. const next: Record<string, RepliedMark> = {}
  141. for (const [k, v] of Object.entries(prior)) {
  142. if (k !== key && now - v.at < REPLIED_MARK_MAX_AGE_MS) next[k] = v
  143. }
  144. next[key] = mark
  145. await env.DASHBOARD.put(SEND_KEY.replied, JSON.stringify(next))
  146. }
  147. /**
  148. * Does this look like an address we can hand to Gmail?
  149. *
  150. * Deliberately not an RFC 5322 validator: that grammar admits quoted local
  151. * parts and bare hostnames, and neither belongs in a box a person types a
  152. * client's email into. One `@`, no whitespace, a dot in the domain. Anything
  153. * stricter starts rejecting real addresses; anything looser starts accepting
  154. * things that are not addresses at all.
  155. */
  156. const looksLikeAddress = (a: string): boolean => /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]{2,}$/.test(a)
  157. export type Recipients =
  158. | { ok: true; header: string; addresses: string[] }
  159. | { ok: false; error: string }
  160. /**
  161. * Parse an address list and re-emit it from the parse.
  162. *
  163. * The re-emission is the security property, not the validation. What goes into
  164. * the header is built out of strings that each matched `looksLikeAddress`, so
  165. * a CR, an LF, a `,` or a `"` cannot reach a header from here whatever anyone
  166. * types; there is no path by which unparsed input is copied through. That is
  167. * what replaces "the recipient never comes from the form" for the compose box,
  168. * and it is why the address is kept and the display name is not: `Lena Ortiz
  169. * <lena@x>` goes out as `lena@x`. The name is cosmetic and it is the half that
  170. * can contain anything.
  171. *
  172. * Duplicates collapse on the normalized address (so `Lena@X.com` and
  173. * `lena@x.com` are one person, and one Cc line), while the original spelling
  174. * is what gets sent. normalizeAddress strips `+tags`, which is right for
  175. * grouping senders in the list view and wrong for addressing mail.
  176. *
  177. * Over the cap is refused with the count. See RECIPIENT_CAP.
  178. */
  179. export function parseRecipients(raw: string, what = 'recipient'): Recipients {
  180. const entries = splitAddressList(raw)
  181. const addresses: string[] = []
  182. const seen = new Set<string>()
  183. for (const entry of entries) {
  184. const { sender } = parseFrom(entry)
  185. // parseFrom lowercases and strips +tags; for addressing we want what was
  186. // typed, so the angle-bracket content is taken verbatim and only used to
  187. // key the duplicate check through normalizeAddress.
  188. const m = /<([^>]+)>\s*$/.exec(entry.trim())
  189. const literal = (m ? m[1]! : entry).trim()
  190. if (!looksLikeAddress(literal)) {
  191. return { ok: false, error: `"${entry}" is not an address this board will send to. Check the ${what} list.` }
  192. }
  193. const key = normalizeAddress(sender || literal)
  194. if (seen.has(key)) continue
  195. seen.add(key)
  196. addresses.push(literal)
  197. }
  198. if (!addresses.length) return { ok: false, error: `No ${what} was given, so nothing was sent.` }
  199. if (addresses.length > RECIPIENT_CAP) {
  200. return {
  201. ok: false,
  202. 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.`,
  203. }
  204. }
  205. return { ok: true, header: addresses.join(', '), addresses }
  206. }
  207. /**
  208. * Everyone on the original except us: the reply-all Cc, derived on the server.
  209. *
  210. * The list is built from the fetched original's To and Cc (never from the
  211. * form, which carries only the boolean), so reply-all keeps the property plain
  212. * reply has.
  213. *
  214. * What comes out, and why each exclusion:
  215. * the primary recipient already in To; a Cc copy of it is a second copy
  216. * the sending mailbox Cc'ing yourself puts your own reply in your inbox
  217. * the mailbox it arrived in same, when replying as a different mailbox
  218. * Everything else stays, including other Meridian addresses: `help@` sitting on
  219. * a client thread is a colleague who is following it, and dropping them is
  220. * exactly the silent omission this board must not make.
  221. */
  222. export function replyAllCc(orig: MessageBody, from: string, to: string): Recipients | null {
  223. const raw = [orig.to, orig.cc].filter(Boolean).join(', ')
  224. if (!raw.trim()) return null
  225. const drop = new Set(
  226. [to, from, orig.mailbox].filter(Boolean).map((a) => normalizeAddress(parseFrom(a).sender || a)),
  227. )
  228. const kept: string[] = []
  229. for (const entry of splitAddressList(raw)) {
  230. const m = /<([^>]+)>\s*$/.exec(entry.trim())
  231. const literal = (m ? m[1]! : entry).trim()
  232. if (!looksLikeAddress(literal)) continue // a malformed entry on someone else's header is not ours to fail on
  233. if (drop.has(normalizeAddress(parseFrom(entry).sender || literal))) continue
  234. kept.push(literal)
  235. }
  236. if (!kept.length) return null
  237. return parseRecipients(kept.join(', '), 'Cc')
  238. }
  239. /**
  240. * Turn the original message plus a choice of mailbox into the exact thing that
  241. * will go out. The only place a reply's recipient is decided.
  242. *
  243. * Everything here reads from `orig`, which came from Gmail on this request, and
  244. * nothing reads from the form. The form carries which message, which mailbox to
  245. * send as, the typed text, whether to include everyone, and a nonce: nothing
  246. * that steers where mail goes.
  247. *
  248. * `replyAll` failing is not silent. When the derived Cc is over the recipient
  249. * cap this returns the error rather than a message with a trimmed Cc: a
  250. * reply-all that quietly drops people is the failure mode reply-all has.
  251. */
  252. export function decideReply(
  253. orig: MessageBody,
  254. from: string,
  255. text: string,
  256. signatureHtml: string | null,
  257. opts: { replyAll?: boolean; attachments?: OutgoingAttachment[] } = {},
  258. ): OutgoingMessage | { error: string } {
  259. // Reply-To wins over From when it parses to something with an @. A sender
  260. // that asked for replies elsewhere gets them there; a malformed Reply-To
  261. // falls back rather than producing a reply addressed to nothing.
  262. const replyToAddr = orig.replyTo ? parseFrom(orig.replyTo).sender : ''
  263. const to = replyToAddr.includes('@') ? replyToAddr : orig.sender
  264. const refs = [orig.references, orig.messageId].filter((x): x is string => Boolean(x))
  265. let cc: string | null = null
  266. if (opts.replyAll) {
  267. const derived = replyAllCc(orig, from, to)
  268. if (derived && !derived.ok) return { error: derived.error }
  269. cc = derived?.ok ? derived.header : null
  270. }
  271. return {
  272. from,
  273. to,
  274. cc,
  275. subject: replySubject(orig.subject),
  276. text,
  277. signatureHtml,
  278. inReplyTo: orig.messageId,
  279. references: refs.length ? refs.join(' ') : null,
  280. // Gmail thread ids are per-mailbox. Passing the id a message has in hello@
  281. // through accounts@'s token is rejected outright, so it is sent only when
  282. // replying from the mailbox the message arrived in. The recipient's client
  283. // threads on In-Reply-To/References either way; what differs is whether the
  284. // sending mailbox's own Sent folder shows it in the same thread.
  285. threadId: from === orig.mailbox && orig.threadId ? orig.threadId : null,
  286. attachments: opts.attachments ?? [],
  287. // Nothing identifies the person behind a shared mailbox. Attribution is
  288. // wanted only if it is truly invisible to the client, and an X- header is
  289. // not: "Show original" displays every header.
  290. // So the outgoing message carries none, and who sent it lives only in
  291. // inbox:sent:* and in the mailbox's own Sent folder.
  292. }
  293. }
  294. /**
  295. * A message with no original: the one place recipients come from a form.
  296. *
  297. * Every other send on this board answers something a client sent us, which is
  298. * what made "the recipient is never in the request" possible. A compose box
  299. * cannot work that way, so this function is the boundary where that stops
  300. * being true, and it is written as its own function (rather than a branch
  301. * inside decideReply) so that the boundary has a name and a place to hold
  302. * this comment.
  303. *
  304. * No threading fields at all: no In-Reply-To, no References, no thread id.
  305. * A new message that claimed a thread would land inside an unrelated
  306. * conversation in the recipient's client.
  307. */
  308. export function decideNew(input: {
  309. from: string
  310. to: string
  311. cc: string | null
  312. subject: string
  313. text: string
  314. signatureHtml: string | null
  315. attachments: OutgoingAttachment[]
  316. }): OutgoingMessage {
  317. return {
  318. from: input.from,
  319. to: input.to,
  320. cc: input.cc,
  321. // An empty subject is legal and Gmail sends it; it is labelled rather than
  322. // invented, the same way the message list labels one.
  323. subject: input.subject.trim() || '(no subject)',
  324. text: input.text,
  325. signatureHtml: input.signatureHtml,
  326. inReplyTo: null,
  327. references: null,
  328. threadId: null,
  329. attachments: input.attachments,
  330. }
  331. }
  332. /**
  333. * A file on its way out, staged as text so it can cross the confirmation step.
  334. *
  335. * Base64 in a hidden field rather than KV. The compose page uploads, the
  336. * confirmation page shows what will go, and the send happens on a third
  337. * request, so the bytes have to survive two hops. KV is the obvious store and
  338. * is wrong for three reasons, in order of weight:
  339. *
  340. * KV is eventually consistent (up to about a minute), so the confirm
  341. * or the send could read back nothing for a draft that was definitely
  342. * written, and the failure would land on the one action that cannot be
  343. * retried safely.
  344. *
  345. * It would put client files at rest in the board's own storage, with a TTL
  346. * to get wrong. body.ts already argues this for message bodies: on demand
  347. * means the board never accumulates seven people's mail.
  348. *
  349. * It would be more board-owned state (rule 12) for something that is in
  350. * flight for ninety seconds.
  351. *
  352. * The cost is that the browser re-posts the files once, and that the caps in
  353. * config/inbox.ts are what keep that re-post small. Both are stated on the
  354. * page.
  355. */
  356. export interface StagedAttachment {
  357. name: string
  358. mimeType: string
  359. /** Standard base64 of the file's bytes. */
  360. b64: string
  361. }
  362. /** Bytes -> base64, chunked past the argument limit (a 3 MB spread throws). */
  363. const b64FromBytes = (bytes: Uint8Array): string => {
  364. let bin = ''
  365. for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode(...bytes.subarray(i, i + 8192))
  366. return btoa(bin)
  367. }
  368. /** base64 -> bytes. Same decoder the download route uses, via the client. */
  369. const bytesFromB64 = (b64: string): Uint8Array => {
  370. const bin = atob(b64)
  371. const out = new Uint8Array(bin.length)
  372. for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
  373. return out
  374. }
  375. /**
  376. * How big the staged form is allowed to be, in base64 characters.
  377. *
  378. * Derived from the byte cap rather than typed twice: 4/3 of the bytes, plus a
  379. * kilobyte of JSON scaffolding per file. A hand-built post that skips the
  380. * upload step and puts a 40 MB string straight in the hidden field is refused
  381. * by this before anything is decoded; the decode is where the memory goes.
  382. */
  383. const STAGED_CAP = Math.ceil((ATTACHMENT_LIMITS.total * 4) / 3) + ATTACHMENT_LIMITS.count * 1024
  384. /** Bytes, as a person reads them. Not KB when the cap is quoted in MB. */
  385. const mb = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MB`
  386. export type Uploads = { ok: true; files: StagedAttachment[] } | { ok: false; error: string }
  387. /**
  388. * The shape of an uploaded file, duck-typed rather than `File`.
  389. *
  390. * The Worker's own FormData types entries as strings, so the signature-upload
  391. * handler already reaches for a cast here; this is the same move with the
  392. * fields an attachment needs. Checking for the methods rather than the class
  393. * is also what makes `readUploads` testable without a DOM File.
  394. */
  395. type UploadLike = {
  396. name: string
  397. size: number
  398. type?: string
  399. arrayBuffer: () => Promise<ArrayBuffer>
  400. }
  401. /**
  402. * The files off a multipart compose form, checked against the caps.
  403. *
  404. * An empty file input submits a file. Browsers post a zero-byte entry with an
  405. * empty name for `<input type="file">` when nothing was chosen, so those are
  406. * dropped here. Without that, every reply would carry one nameless empty
  407. * attachment and the count check would fail on the fifth real file.
  408. *
  409. * Over any cap refuses the whole submission and names the file and the size.
  410. * Nothing is trimmed to fit: a message that went out believing it carried a
  411. * file it did not is worse than a message that was refused.
  412. */
  413. export async function readUploads(form: FormData): Promise<Uploads> {
  414. const raw = (form.getAll('files') as unknown[]).filter(
  415. (v): v is UploadLike =>
  416. typeof v === 'object' && v !== null && 'arrayBuffer' in v && 'size' in v && 'name' in v,
  417. )
  418. const picked = raw.filter((f) => f.size > 0 || f.name.trim() !== '')
  419. if (!picked.length) return { ok: true, files: [] }
  420. if (picked.length > ATTACHMENT_LIMITS.count) {
  421. return {
  422. ok: false,
  423. error: `${picked.length} files were attached; this board sends at most ${ATTACHMENT_LIMITS.count} on one message. Nothing was sent.`,
  424. }
  425. }
  426. let total = 0
  427. const files: StagedAttachment[] = []
  428. for (const f of picked) {
  429. if (f.size > ATTACHMENT_LIMITS.perFile) {
  430. return {
  431. ok: false,
  432. error: `"${f.name}" is ${mb(f.size)}, over the ${mb(ATTACHMENT_LIMITS.perFile)} per-file limit. Nothing was sent. Send that one from Gmail.`,
  433. }
  434. }
  435. total += f.size
  436. if (total > ATTACHMENT_LIMITS.total) {
  437. return {
  438. ok: false,
  439. error: `Those files come to ${mb(total)}, over the ${mb(ATTACHMENT_LIMITS.total)} total. Nothing was sent.`,
  440. }
  441. }
  442. files.push({
  443. name: f.name,
  444. mimeType: f.type || 'application/octet-stream',
  445. b64: b64FromBytes(new Uint8Array(await f.arrayBuffer())),
  446. })
  447. }
  448. return { ok: true, files }
  449. }
  450. /**
  451. * Read the staged files back off the confirmation form.
  452. *
  453. * Re-checked, not trusted. The compose step already applied the caps, and this
  454. * applies them again to the decoded bytes, because between the two steps the
  455. * payload was in a form field on somebody's browser. The confirmation page is
  456. * a page like any other: what it hands back is input.
  457. */
  458. export function parseStaged(raw: string): Uploads {
  459. if (!raw.trim()) return { ok: true, files: [] }
  460. if (raw.length > STAGED_CAP) {
  461. return { ok: false, error: 'The attached files were larger than this board accepts. Nothing was sent.' }
  462. }
  463. let parsed: unknown
  464. try {
  465. parsed = JSON.parse(raw)
  466. } catch {
  467. return { ok: false, error: 'The attached files did not survive the confirmation step, so nothing was sent. Attach them again.' }
  468. }
  469. if (!Array.isArray(parsed)) {
  470. return { ok: false, error: 'The attached files did not survive the confirmation step, so nothing was sent. Attach them again.' }
  471. }
  472. if (parsed.length > ATTACHMENT_LIMITS.count) {
  473. return { ok: false, error: `More than ${ATTACHMENT_LIMITS.count} files were submitted. Nothing was sent.` }
  474. }
  475. const files: StagedAttachment[] = []
  476. for (const v of parsed) {
  477. const f = v as Partial<StagedAttachment>
  478. if (typeof f?.name !== 'string' || typeof f.mimeType !== 'string' || typeof f.b64 !== 'string') {
  479. return { ok: false, error: 'One of the attached files arrived malformed, so nothing was sent.' }
  480. }
  481. files.push({ name: f.name, mimeType: f.mimeType, b64: f.b64 })
  482. }
  483. return { ok: true, files }
  484. }
  485. /** Staged text -> the bytes the encoder wants, with the size caps re-applied. */
  486. export function stagedToOutgoing(files: StagedAttachment[]): { ok: true; files: OutgoingAttachment[] } | { ok: false; error: string } {
  487. let total = 0
  488. const out: OutgoingAttachment[] = []
  489. for (const f of files) {
  490. let bytes: Uint8Array
  491. try {
  492. bytes = bytesFromB64(f.b64)
  493. } catch {
  494. return { ok: false, error: `"${f.name}" could not be decoded, so nothing was sent.` }
  495. }
  496. if (bytes.byteLength > ATTACHMENT_LIMITS.perFile) {
  497. return { ok: false, error: `"${f.name}" is ${mb(bytes.byteLength)}, over the ${mb(ATTACHMENT_LIMITS.perFile)} per-file limit. Nothing was sent.` }
  498. }
  499. total += bytes.byteLength
  500. if (total > ATTACHMENT_LIMITS.total) {
  501. return { ok: false, error: `The attached files come to ${mb(total)}, over the ${mb(ATTACHMENT_LIMITS.total)} total. Nothing was sent.` }
  502. }
  503. out.push({ name: f.name, mimeType: f.mimeType, bytes })
  504. }
  505. return { ok: true, files: out }
  506. }
  507. /** What the audit row records about the files. Names and sizes, never bytes. */
  508. export const sentAttachments = (files: OutgoingAttachment[]): SentAttachment[] =>
  509. files.map((f) => ({ name: f.name, size: f.bytes.byteLength }))
  510. export interface SendDeps {
  511. fetchBody: (mailbox: string, id: string, cred: GmailCredential) => Promise<MessageBody | { error: string }>
  512. sendMessage: (mailbox: string, cred: GmailCredential, msg: OutgoingMessage) => Promise<SendResult>
  513. credentialFor: (mailbox: string) => GmailCredential | { error: string }
  514. now: () => number
  515. }
  516. /** A form value, trimmed. Never `undefined` reaching a comparison. */
  517. const field = (form: FormData, name: string): string => String(form.get(name) ?? '').trim()
  518. export type SendMode = 'reply' | 'new'
  519. /** 'new' only when the form says so. An unknown value is a reply, never a new
  520. * message: the reply path is the one that cannot address a stranger. */
  521. const modeOf = (form: FormData): SendMode => (String(form.get('mode') ?? '') === 'new' ? 'new' : 'reply')
  522. export type SendOutcome =
  523. | { kind: 'denied' }
  524. | { kind: 'redirect'; location: string }
  525. | { kind: 'error'; message: string }
  526. | {
  527. kind: 'confirm'
  528. mode: SendMode
  529. /** The message being answered. Null for a new message, which has none. */
  530. orig: MessageBody | null
  531. outgoing: OutgoingMessage
  532. nonce: string
  533. /** Carried through the confirmation page in a hidden field. */
  534. staged: StagedAttachment[]
  535. }
  536. /**
  537. * The recipient fields of a new message, validated together.
  538. *
  539. * The two lists are capped as a sum, not one each: fifteen in To and fifteen
  540. * in Cc is thirty people, which is the thing the cap exists to prevent.
  541. */
  542. function newRecipients(form: FormData): { to: string; cc: string | null } | { error: string } {
  543. const to = parseRecipients(String(form.get('to') ?? ''), 'recipient')
  544. if (!to.ok) return { error: to.error }
  545. const rawCc = String(form.get('cc') ?? '').trim()
  546. if (!rawCc) return { to: to.header, cc: null }
  547. const cc = parseRecipients(rawCc, 'Cc')
  548. if (!cc.ok) return { error: cc.error }
  549. if (to.addresses.length + cc.addresses.length > RECIPIENT_CAP) {
  550. return {
  551. 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.`,
  552. }
  553. }
  554. return { to: to.header, cc: cc.header }
  555. }
  556. /**
  557. * The confirmation step. Nothing is sent and nothing is written.
  558. *
  559. * It re-fetches the original rather than trusting anything the compose form
  560. * carried, so the addresses shown on the confirmation page are the addresses
  561. * the send will actually use; a preview computed from different inputs than
  562. * the action is a preview of nothing.
  563. */
  564. export async function handleCompose(
  565. viewer: Viewer,
  566. env: SendEnv,
  567. form: FormData,
  568. readable: string[],
  569. deps: SendDeps,
  570. ): Promise<SendOutcome> {
  571. const mode = modeOf(form)
  572. const from = field(form, 'from').toLowerCase()
  573. const text = String(form.get('body') ?? '')
  574. const nonce = field(form, 'nonce')
  575. // Sending as `from` needs the send grant, whatever the mode. Reading an
  576. // original needs the read grant as well, and that check is inside the reply
  577. // branch: two checks because they are two different permissions, which is
  578. // the whole reason INBOX_SENDERS exists as its own list.
  579. if (!from || !canSendFrom(viewer, from)) return { kind: 'denied' }
  580. 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.' }
  581. if (!text.trim()) return { kind: 'error', message: 'The message was empty, so nothing was sent.' }
  582. const uploads = await readUploads(form)
  583. if (!uploads.ok) return { kind: 'error', message: uploads.error }
  584. const sigs = await readSignatures(env)
  585. const signatureHtml = sigs.ok ? (sigs.map[from]?.html ?? null) : null
  586. // The bytes are decoded here only to be re-encoded by the sender later. It
  587. // is the one place both forms exist at once, and it is what lets the
  588. // confirmation page show a real size rather than a claimed one.
  589. const decoded = stagedToOutgoing(uploads.files)
  590. if (!decoded.ok) return { kind: 'error', message: decoded.error }
  591. if (mode === 'new') {
  592. const rcpt = newRecipients(form)
  593. if ('error' in rcpt) return { kind: 'error', message: rcpt.error }
  594. return {
  595. kind: 'confirm',
  596. mode,
  597. orig: null,
  598. outgoing: decideNew({
  599. from,
  600. to: rcpt.to,
  601. cc: rcpt.cc,
  602. subject: field(form, 'subject'),
  603. text,
  604. signatureHtml,
  605. attachments: decoded.files,
  606. }),
  607. nonce,
  608. staged: uploads.files,
  609. }
  610. }
  611. const mailbox = field(form, 'mailbox').toLowerCase()
  612. const id = field(form, 'id')
  613. if (!mailbox || !id || !readable.includes(mailbox)) return { kind: 'denied' }
  614. const cred = deps.credentialFor(mailbox)
  615. if ('error' in cred) return { kind: 'error', message: cred.error }
  616. const orig = await deps.fetchBody(mailbox, id, cred)
  617. if ('error' in orig) return { kind: 'error', message: orig.error }
  618. const outgoing = decideReply(orig, from, text, signatureHtml, {
  619. replyAll: field(form, 'all') === '1',
  620. attachments: decoded.files,
  621. })
  622. if ('error' in outgoing) return { kind: 'error', message: outgoing.error }
  623. return { kind: 'confirm', mode, orig, outgoing, nonce, staged: uploads.files }
  624. }
  625. /**
  626. * The send. The one outbound write.
  627. *
  628. * The order is the point:
  629. * 1. claim the nonce: a record already there means this was submitted before
  630. * 2. write 'sending': before Gmail is called, so a Worker torn down
  631. * mid-flight leaves evidence rather than silence
  632. * 3. call Gmail
  633. * 4. overwrite with the outcome, and on success set the persistent replied mark
  634. *
  635. * The residual is honest and stated on the page: KV is eventually consistent
  636. * across edge locations (up to about a minute), so two posts of the same nonce
  637. * from different locations inside that window could both pass. A back-button
  638. * resubmit from one browser hits one location and is caught.
  639. */
  640. export async function handleSend(
  641. viewer: Viewer,
  642. env: SendEnv,
  643. form: FormData,
  644. readable: string[],
  645. deps: SendDeps,
  646. ): Promise<SendOutcome> {
  647. const mode = modeOf(form)
  648. const from = field(form, 'from').toLowerCase()
  649. const text = String(form.get('body') ?? '')
  650. const nonce = field(form, 'nonce')
  651. if (!from || !canSendFrom(viewer, from)) return { kind: 'denied' }
  652. if (!nonce || !/^[A-Za-z0-9-]{8,64}$/.test(nonce)) {
  653. return { kind: 'error', message: 'That form was submitted without a usable one-time token, so nothing was sent.' }
  654. }
  655. if (!text.trim()) return { kind: 'error', message: 'The message was empty, so nothing was sent.' }
  656. // The staged files are re-validated, and before the nonce is claimed: a
  657. // submission that fails the caps must be re-submittable, so it must not burn
  658. // the one-time token on its way to being refused.
  659. const staged = parseStaged(String(form.get('staged') ?? ''))
  660. if (!staged.ok) return { kind: 'error', message: staged.error }
  661. const decoded = stagedToOutgoing(staged.files)
  662. if (!decoded.ok) return { kind: 'error', message: decoded.error }
  663. const mailbox = field(form, 'mailbox').toLowerCase()
  664. const id = field(form, 'id')
  665. if (mode === 'reply' && (!mailbox || !id || !readable.includes(mailbox))) return { kind: 'denied' }
  666. const key = `${SEND_KEY.sentPrefix}${nonce}`
  667. const claimed = await env.DASHBOARD.get(key)
  668. if (claimed) {
  669. return { kind: 'redirect', location: `/inbox/sent?ref=${encodeURIComponent(nonce)}&dup=1` }
  670. }
  671. const cred = deps.credentialFor(from)
  672. if ('error' in cred) return { kind: 'error', message: cred.error }
  673. const sigs = await readSignatures(env)
  674. const signatureHtml = sigs.ok ? (sigs.map[from]?.html ?? null) : null
  675. let outgoing: OutgoingMessage
  676. let orig: MessageBody | null = null
  677. if (mode === 'new') {
  678. const rcpt = newRecipients(form)
  679. if ('error' in rcpt) return { kind: 'error', message: rcpt.error }
  680. outgoing = decideNew({
  681. from,
  682. to: rcpt.to,
  683. cc: rcpt.cc,
  684. subject: field(form, 'subject'),
  685. text,
  686. signatureHtml,
  687. attachments: decoded.files,
  688. })
  689. } else {
  690. const origCred = deps.credentialFor(mailbox)
  691. if ('error' in origCred) return { kind: 'error', message: origCred.error }
  692. const fetched = await deps.fetchBody(mailbox, id, origCred)
  693. if ('error' in fetched) return { kind: 'error', message: fetched.error }
  694. orig = fetched
  695. // Re-derived from the original, not carried from the confirmation page.
  696. // The preview and the send are computed from the same inputs by the same
  697. // function, which is the only way the preview is a preview of anything.
  698. const decided = decideReply(fetched, from, text, signatureHtml, {
  699. replyAll: field(form, 'all') === '1',
  700. attachments: decoded.files,
  701. })
  702. if ('error' in decided) return { kind: 'error', message: decided.error }
  703. outgoing = decided
  704. }
  705. const at = new Date(deps.now()).toISOString()
  706. const record: SendRecord = {
  707. nonce,
  708. at,
  709. by: viewer.person?.name ?? viewer.email ?? 'unknown',
  710. byEmail: viewer.email ?? '',
  711. from,
  712. to: outgoing.to,
  713. cc: outgoing.cc,
  714. subject: outgoing.subject,
  715. mode,
  716. // Null, not '', for a new message. There is no original, and an empty
  717. // string in a field that holds a mailbox address would render as one that
  718. // could not be read (the prime invariant, applied to the audit row).
  719. originalMailbox: mode === 'reply' ? mailbox : null,
  720. originalId: mode === 'reply' ? id : null,
  721. originalMessageId: orig?.messageId ?? null,
  722. threadIdSent: outgoing.threadId,
  723. signatureApplied: signatureHtml !== null,
  724. attachments: sentAttachments(decoded.files),
  725. outcome: 'sending',
  726. gmailId: null,
  727. gmailThreadId: null,
  728. error: null,
  729. candidates: [],
  730. }
  731. await writeRecord(env, record)
  732. const result = await deps.sendMessage(from, cred, outgoing)
  733. const final: SendRecord = result.ok
  734. ? { ...record, outcome: 'sent', gmailId: result.id, gmailThreadId: result.threadId }
  735. : { ...record, outcome: 'failed', error: result.error, candidates: result.candidates }
  736. await writeRecord(env, final)
  737. if (result.ok && mode === 'reply') {
  738. // The mark goes on after Gmail accepts, and only then. A "replied" chip on
  739. // a message nobody answered is worse than no chip at all in a shared
  740. // mailbox; it is what makes someone else not answer it.
  741. await markReplied(env, `${mailbox}/${id}`, { at: deps.now(), by: record.by, from }, deps.now())
  742. }
  743. return { kind: 'redirect', location: `/inbox/sent?ref=${encodeURIComponent(nonce)}` }
  744. }
  745. /**
  746. * One KV key per attempt, not one array everyone appends to: two people
  747. * replying at the same moment would each read the array, add their row, and
  748. * write it back, and one of them would lose their record entirely.
  749. *
  750. * The metadata carries a trimmed copy so the listing can render without reading
  751. * any values.
  752. */
  753. async function writeRecord(env: SendEnv, r: SendRecord): Promise<void> {
  754. await env.DASHBOARD.put(`${SEND_KEY.sentPrefix}${r.nonce}`, JSON.stringify(r), {
  755. // KV caps metadata at 1,024 bytes, hence the subject cut, and hence the
  756. // attachments being reduced to a count here rather than a list of names.
  757. // Five long filenames would push the row over the cap, and a metadata
  758. // write that fails takes the whole listing row with it.
  759. metadata: {
  760. ...r,
  761. subject: r.subject.slice(0, 120),
  762. candidates: [],
  763. attachments: null,
  764. attachmentCount: r.attachments?.length ?? null,
  765. cc: r.cc ? r.cc.slice(0, 120) : r.cc,
  766. },
  767. })
  768. }
  769. export async function readRecord(env: SendEnv, nonce: string): Promise<SendRecord | null> {
  770. const raw = await env.DASHBOARD.get(`${SEND_KEY.sentPrefix}${nonce}`)
  771. if (!raw) return null
  772. try {
  773. return JSON.parse(raw) as SendRecord
  774. } catch {
  775. return null
  776. }
  777. }
  778. /**
  779. * The listing. Capped and labelled capped (rule 5): a truncated list that
  780. * claims to be complete is the failure mode; throwing away rows to avoid one is
  781. * the other.
  782. */
  783. export async function listSent(
  784. env: SendEnv,
  785. limit = 200,
  786. ): Promise<{ rows: SendRecord[]; truncated: boolean }> {
  787. const res = await env.DASHBOARD.list({ prefix: SEND_KEY.sentPrefix, limit })
  788. const rows = res.keys
  789. .map((k) => k.metadata as SendRecord | undefined)
  790. .filter((r): r is SendRecord => Boolean(r && r.at && r.from))
  791. .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
  792. return { rows, truncated: !res.list_complete }
  793. }
  794. export type SignatureOutcome =
  795. | { kind: 'denied' }
  796. | { kind: 'redirect'; location: string }
  797. /**
  798. * Set or clear one mailbox's signature.
  799. *
  800. * `canSendFrom`, never `readableMailboxes`: the whole roster may read every
  801. * mailbox, and a signature is what goes out over that mailbox's name.
  802. *
  803. * Over the cap writes nothing. Not a truncated signature: a signature cut at
  804. * 20,000 characters would go out as broken HTML on every reply, and the person
  805. * who pasted it would have no reason to look.
  806. */
  807. export async function handleSignaturesPost(
  808. viewer: Viewer,
  809. env: SendEnv,
  810. form: FormData,
  811. now: () => number,
  812. ): Promise<SignatureOutcome> {
  813. const mailbox = field(form, 'mailbox').toLowerCase()
  814. const op = field(form, 'op')
  815. if (!mailbox || !canSendFrom(viewer, mailbox)) return { kind: 'denied' }
  816. if (op !== 'set' && op !== 'clear') return { kind: 'redirect', location: '/inbox/signatures' }
  817. const store = await readSignatures(env)
  818. // A store that could not be read must not be overwritten: doing so would turn
  819. // a transient read failure into the permanent loss of everyone's signature.
  820. if (!store.ok) return { kind: 'redirect', location: '/inbox/signatures' }
  821. const next = { ...store.map }
  822. if (op === 'clear') {
  823. delete next[mailbox]
  824. await env.DASHBOARD.put(SEND_KEY.signatures, JSON.stringify(next))
  825. return { kind: 'redirect', location: '/inbox/signatures' }
  826. }
  827. const uploaded = form.get('file')
  828. const fromFile =
  829. uploaded && typeof uploaded === 'object' && 'text' in uploaded
  830. ? await (uploaded as unknown as { text: () => Promise<string> }).text()
  831. : ''
  832. const html = (fromFile.trim() ? fromFile : String(form.get('html') ?? '')).trim()
  833. if (html.length > SIGNATURE_CAP) {
  834. return {
  835. kind: 'redirect',
  836. location: `/inbox/signatures?rejected=${encodeURIComponent(mailbox)}&over=${html.length}`,
  837. }
  838. }
  839. if (!html) {
  840. // Empty after trim is a clear; the control that writes can unwrite, as
  841. // with the board's notes.
  842. delete next[mailbox]
  843. } else {
  844. next[mailbox] = { html, by: viewer.person?.name ?? viewer.email ?? 'unknown', at: new Date(now()).toISOString() }
  845. }
  846. await env.DASHBOARD.put(SEND_KEY.signatures, JSON.stringify(next))
  847. return { kind: 'redirect', location: '/inbox/signatures' }
  848. }