@novacraft-engineering/mailbox 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
package/lib/mailbox.ts CHANGED
@@ -11,12 +11,14 @@ import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
11
11
  import { d1, d1Batch, d1Query } from './d1'
12
12
  import { stripCidPlaceholders } from './email-html'
13
13
  import { hashPassword } from './password'
14
+ import { duration, type ParsedQuery } from '@/app/mail/search'
14
15
  import { turso, tursoBatch, tursoQuery } from './turso'
15
16
  import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
16
17
 
17
18
  export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
18
19
 
19
20
  export type InboundEmail = {
21
+ snoozedUntil?: string | null
20
22
  id: string
21
23
  from: string
22
24
  to: string[]
@@ -143,6 +145,11 @@ export function ensureMailSchema(): Promise<void> {
143
145
  // sort it and then discards all but one page.
144
146
  `CREATE INDEX IF NOT EXISTS mail_inbox_list_idx ON mail_inbox (lower(owner), archived, trashed, received_at DESC, id DESC)`,
145
147
  `CREATE INDEX IF NOT EXISTS mail_inbox_owner_recent_idx ON mail_inbox (lower(owner), received_at DESC, id DESC)`,
148
+ // Trash and Starred cannot use the list index: archived sits ahead of trashed in it
149
+ // and is unconstrained for those two folders, so SQLite fell back to walking the
150
+ // mailbox in date order — 45,000 rows read to find the 52 that were in the bin.
151
+ `CREATE INDEX IF NOT EXISTS mail_inbox_owner_trash_idx ON mail_inbox (lower(owner), trashed, received_at DESC, id DESC)`,
152
+ `CREATE INDEX IF NOT EXISTS mail_inbox_owner_starred_idx ON mail_inbox (lower(owner), starred, trashed, received_at DESC, id DESC)`,
146
153
  // The folder counts read only these five columns. Without them all in one index
147
154
  // SQLite walks the rows themselves, and a row here can carry 50KB of html — which
148
155
  // turned a five-number summary into a 37-second scan on the larger mailboxes.
@@ -350,6 +357,15 @@ export function ensureMailSchema(): Promise<void> {
350
357
  await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN thread_id TEXT').catch(() => {})
351
358
  await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_thread_idx ON mail_inbox (lower(owner), thread_id, received_at DESC, id DESC)').catch(() => {})
352
359
 
360
+ // Snooze. An ISO timestamp in the future means "not now": the message is out of the
361
+ // inbox until then and comes back on its own, because every folder decides from this
362
+ // column against the clock rather than from a flag some worker has to flip. There is
363
+ // no job to fall behind, and nothing to reconcile if one does.
364
+ await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN snoozed_until TEXT').catch(() => {})
365
+ await sqlRaw('ALTER TABLE mail_threads ADD COLUMN snoozed_until TEXT').catch(() => {})
366
+ await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_snoozed_idx ON mail_inbox (lower(owner), snoozed_until, received_at DESC, id DESC)').catch(() => {})
367
+ await sqlRaw('CREATE INDEX IF NOT EXISTS mail_threads_snoozed_idx ON mail_threads (owner, snoozed_until, latest_at DESC)').catch(() => {})
368
+
353
369
  // Seed the mailboxes this deployment serves — metadata only, never clobber an
354
370
  // existing password_hash.
355
371
  const sql = db()
@@ -399,6 +415,7 @@ function mapInbound(row: Record<string, unknown>): InboundEmail {
399
415
  starred: Boolean(row.starred),
400
416
  archived: Boolean(row.archived),
401
417
  trashed: Boolean(row.trashed),
418
+ snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
402
419
  labels: parseArray(row.labels),
403
420
  owner: (row.owner as string) ?? null,
404
421
  threadId: row.thread_id == null ? null : String(row.thread_id),
@@ -427,7 +444,7 @@ export type InboxPage = {
427
444
  export async function searchInbox(options: {
428
445
  text?: string
429
446
  owner?: string
430
- folder?: 'inbox' | 'archive' | 'trash' | 'starred'
447
+ folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
431
448
  unread?: boolean
432
449
  starred?: boolean
433
450
  hasAttachment?: boolean
@@ -469,10 +486,15 @@ export async function searchInbox(options: {
469
486
  if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
470
487
  if (options.to) { where.push('lower(m.to_addrs) LIKE ?'); args.push(`%${options.to.toLowerCase()}%`) }
471
488
 
489
+ // A snoozed message is only out of the inbox while its time is still ahead; the clause
490
+ // does the waking, so nothing has to run on a timer.
491
+ const nowIso = new Date().toISOString()
492
+ const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
472
493
  if (options.folder === 'trash') where.push('m.trashed = 1')
473
494
  else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0')
474
495
  else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0')
475
- else if (options.folder === 'inbox') where.push('m.archived = 0 AND m.trashed = 0')
496
+ else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0'); args.push(nowIso) }
497
+ else if (options.folder === 'inbox') { where.push(`m.archived = 0 AND m.trashed = 0 AND ${awake}`); args.push(nowIso) }
476
498
 
477
499
  const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
478
500
 
@@ -502,7 +524,7 @@ export async function searchInbox(options: {
502
524
  'contentType', json_extract(value, '$.contentType'),
503
525
  'size', json_extract(value, '$.size')))
504
526
  FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
505
- m.starred, m.archived, m.trashed, m.labels, m.owner, m.thread_id
527
+ m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id
506
528
  FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
507
529
  ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
508
530
  cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
@@ -542,14 +564,21 @@ function decodeCursor(value?: string | null): { receivedAt: string; id: string }
542
564
  }
543
565
  }
544
566
 
545
- export type FolderCounts = {
567
+ export type FolderTally = {
546
568
  inbox: number
547
569
  unread: number
548
570
  starred: number
549
571
  archived: number
550
572
  trashed: number
573
+ snoozed: number
551
574
  }
552
575
 
576
+ /**
577
+ * `conversations` is null when threads are not live, because `mail_threads` is then either
578
+ * empty or half-built and counting it would report a mailbox smaller than it is.
579
+ */
580
+ export type FolderCounts = FolderTally & { conversations: FolderTally | null }
581
+
553
582
  /**
554
583
  * Folder totals for a whole mailbox.
555
584
  *
@@ -577,7 +606,9 @@ export async function countFoldersCached(owner: string): Promise<FolderCounts> {
577
606
  const row = hit[0]
578
607
  if (row && Date.now() - Date.parse(String(row.computed_at)) < COUNTS_CACHE_MS) {
579
608
  const parsed = parseJson<FolderCounts | null>(row.counts, null)
580
- if (parsed) return parsed
609
+ // A row cached before conversations were counted has no such key; recompute rather
610
+ // than serve a shape the caller will read as "threads are not live".
611
+ if (parsed && 'conversations' in parsed) return parsed
581
612
  }
582
613
  const fresh = await countFolders(owner)
583
614
  await sql`
@@ -592,28 +623,63 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
592
623
  const sql = db()
593
624
  const scope = owner ? 'WHERE lower(owner) = ?' : ''
594
625
  const args = owner ? [owner.toLowerCase()] : []
626
+ const nowIso = new Date().toISOString()
595
627
 
596
628
  // One pass, not one per folder: five separate counts read the table five times
597
629
  // (206k rows against 69k here) for figures that all come off the same scan.
598
630
  const rows = await tagged(
599
631
  sql,
600
632
  `SELECT
601
- SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
602
- SUM(CASE WHEN archived = 0 AND trashed = 0 AND read = 0 THEN 1 ELSE 0 END) AS unread,
633
+ SUM(CASE WHEN archived = 0 AND trashed = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
634
+ SUM(CASE WHEN archived = 0 AND trashed = 0 AND read = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
603
635
  SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
604
636
  SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
605
- SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed
637
+ SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
638
+ SUM(CASE WHEN snoozed_until > ? AND trashed = 0 THEN 1 ELSE 0 END) AS snoozed
606
639
  FROM mail_inbox ${scope}`,
607
- args,
640
+ [nowIso, nowIso, nowIso, ...args],
608
641
  )
609
642
  const row = rows[0] ?? {}
610
643
  const value = (key: string) => Number((row[key] as number) ?? 0)
644
+
645
+ // The list shows one row per conversation, so the figure beside a folder has to be
646
+ // conversations too. Counted in messages it disagreed with the list header by three to
647
+ // one on the larger mailboxes, and the unread badge could exceed the folder total.
648
+ // The predicates are the ones listThreads pages by; they must not drift apart.
649
+ let conversations: FolderTally | null = null
650
+ if (threadsLive() && owner) {
651
+ const threadRows = await tagged(
652
+ sql,
653
+ `SELECT
654
+ SUM(CASE WHEN inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
655
+ SUM(CASE WHEN inbox_count > 0 AND unread_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
656
+ SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
657
+ SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
658
+ SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
659
+ SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
660
+ FROM mail_threads WHERE owner = ?`,
661
+ [nowIso, nowIso, nowIso, owner.toLowerCase()],
662
+ )
663
+ const threadRow = threadRows[0] ?? {}
664
+ const threadValue = (key: string) => Number((threadRow[key] as number) ?? 0)
665
+ conversations = {
666
+ inbox: threadValue('inbox'),
667
+ unread: threadValue('unread'),
668
+ starred: threadValue('starred'),
669
+ archived: threadValue('archived'),
670
+ trashed: threadValue('trashed'),
671
+ snoozed: threadValue('snoozed'),
672
+ }
673
+ }
674
+
611
675
  return {
612
676
  inbox: value('inbox'),
613
677
  unread: value('unread'),
614
678
  starred: value('starred'),
615
679
  archived: value('archived'),
616
680
  trashed: value('trashed'),
681
+ snoozed: value('snoozed'),
682
+ conversations,
617
683
  }
618
684
  }
619
685
 
@@ -648,6 +714,7 @@ export type ThreadRow = {
648
714
  senders: string[]
649
715
  snippet: string
650
716
  labels: string[]
717
+ snoozedUntil: string | null
651
718
  }
652
719
 
653
720
  /**
@@ -737,6 +804,8 @@ async function threadMessage(row: { id: string; owner: string | null; subject: s
737
804
  const threadId = await assignThread({ id: row.id, owner: row.owner, subject: row.subject, receivedAt: row.receivedAt })
738
805
  const sql = db()
739
806
  await sql`UPDATE mail_inbox SET thread_id = ${threadId} WHERE id = ${row.id}`
807
+ // A reply to a conversation someone put aside is the reason to stop putting it aside.
808
+ await setThreadSnooze(row.owner, threadId, null)
740
809
  await refreshThread(row.owner, threadId)
741
810
  } catch (err) {
742
811
  console.error('[threads] could not thread', row.id, err instanceof Error ? err.message : err)
@@ -762,7 +831,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
762
831
  }
763
832
  }
764
833
 
765
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred'
834
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
766
835
 
767
836
  /** The newest conversations in a folder: one row each, already summarised. */
768
837
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
@@ -770,19 +839,26 @@ export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
770
839
  export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
771
840
  await ensureMailSchema()
772
841
  const owner = ownerRaw.toLowerCase()
842
+ const nowIso = new Date().toISOString()
773
843
  const predicate =
774
844
  folder === 'archive' ? 'archived_count > 0'
775
845
  : folder === 'trash' ? 'trashed_count > 0'
776
846
  : folder === 'starred' ? 'starred_count > 0'
777
- : 'inbox_count > 0'
847
+ : folder === 'snoozed' ? 'snoozed_until > ?'
848
+ : 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
849
+ // Both snooze predicates carry one bound timestamp; the others carry none, and the
850
+ // cursor's arguments have to follow whatever the predicate used.
851
+ const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
778
852
  const cursor = decodeCursor(cursorRaw)
779
853
  const rows = await tagged(db(), `
780
854
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
781
- inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels
855
+ inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
782
856
  FROM mail_threads WHERE owner = ? AND ${predicate}
783
857
  ${cursor ? 'AND (latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''}
784
858
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
785
- cursor ? [owner, cursor.receivedAt, cursor.receivedAt, cursor.id, limit] : [owner, limit])
859
+ cursor
860
+ ? [owner, ...folderArgs, cursor.receivedAt, cursor.receivedAt, cursor.id, limit]
861
+ : [owner, ...folderArgs, limit])
786
862
  const last = rows[rows.length - 1]
787
863
  const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
788
864
  const mapped = rows.map(row => ({
@@ -801,6 +877,7 @@ export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit:
801
877
  senders: parseJson<string[]>(row.senders, []),
802
878
  snippet: String(row.snippet ?? ''),
803
879
  labels: parseJson<string[]>(row.labels, []),
880
+ snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
804
881
  }))
805
882
  return { rows: mapped, nextCursor }
806
883
  }
@@ -1145,6 +1222,64 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
1145
1222
  await rethreadAfterChange(id)
1146
1223
  }
1147
1224
 
1225
+ /**
1226
+ * Flags every message in one conversation. The list shows a row per thread, and a client
1227
+ * that has not loaded that thread's messages cannot name them — which is why archiving a
1228
+ * row whose mail sat below the loaded page quietly did nothing.
1229
+ */
1230
+ export async function setInboundFlagsForThread(
1231
+ ownerRaw: string,
1232
+ threadId: string,
1233
+ flags: InboundFlags,
1234
+ ): Promise<string[]> {
1235
+ await ensureMailSchema()
1236
+ const sql = db()
1237
+ const owner = ownerRaw.toLowerCase()
1238
+ const rows = await sql`
1239
+ SELECT id FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
1240
+ const ids = rows.map(row => String(row.id))
1241
+ for (const id of ids) await setInboundFlags(id, flags)
1242
+ return ids
1243
+ }
1244
+
1245
+ /**
1246
+ * Snooze or wake a conversation. Snooze is a property of the conversation rather than of
1247
+ * one message in it: the list shows a row per thread, and half a thread disappearing would
1248
+ * be a puzzle rather than a feature. `until` of null wakes it immediately.
1249
+ */
1250
+ export async function setThreadSnooze(
1251
+ ownerRaw: string,
1252
+ threadId: string,
1253
+ until: string | null,
1254
+ ): Promise<number> {
1255
+ await ensureMailSchema()
1256
+ const sql = db()
1257
+ const owner = ownerRaw.toLowerCase()
1258
+ const rows = await sql`
1259
+ SELECT id FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
1260
+ for (const row of rows) {
1261
+ await sql`UPDATE mail_inbox SET snoozed_until = ${until} WHERE id = ${String(row.id)}`
1262
+ }
1263
+ await sql`
1264
+ UPDATE mail_threads SET snoozed_until = ${until}
1265
+ WHERE owner = ${owner} AND thread_id = ${threadId}`
1266
+ await invalidateCounts(owner)
1267
+ return rows.length
1268
+ }
1269
+
1270
+ /** Which of these message ids the mailbox already holds. */
1271
+ export async function inboundExists(ids: string[]): Promise<Set<string>> {
1272
+ await ensureMailSchema()
1273
+ const found = new Set<string>()
1274
+ for (let i = 0; i < ids.length; i += 200) {
1275
+ const chunk = ids.slice(i, i + 200)
1276
+ if (!chunk.length) break
1277
+ const rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk)
1278
+ for (const row of rows) found.add(String(row.id))
1279
+ }
1280
+ return found
1281
+ }
1282
+
1148
1283
  export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
1149
1284
  const sql = db()
1150
1285
  await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
@@ -1428,14 +1563,121 @@ export async function recordSentMessage(message: SentMessage): Promise<void> {
1428
1563
  ON CONFLICT (id) DO NOTHING`
1429
1564
  }
1430
1565
 
1431
- export async function readSentArchive(): Promise<SentMessage[]> {
1566
+ /**
1567
+ * Which columns a search term can narrow in SQL. The precise decision is still made by
1568
+ * matchesQuery on the rows that come back; this only keeps the scan from reading every
1569
+ * body in the archive. A term it cannot express leaves its group unnarrowed rather than
1570
+ * narrowing it wrongly.
1571
+ */
1572
+ const SENT_SEARCH_COLUMNS: Record<string, string> = {
1573
+ to: 's.to_addrs',
1574
+ cc: 's.cc',
1575
+ bcc: 's.bcc',
1576
+ from: 's.from_addr',
1577
+ subject: 's.subject',
1578
+ // Must match the substr the exact matcher is handed, or a deep hit passes SQL then drops.
1579
+ body: "substr(coalesce(s.body_text, ''), 1, 4000)",
1580
+ }
1581
+
1582
+ /** A LIKE pattern for a literal term: its own wildcards are made literal too. */
1583
+ function likeLiteral(value: string): string {
1584
+ return `%${value.replace(/[\\%_]/g, char => `\\${char}`)}%`
1585
+ }
1586
+
1587
+ function sentSearchPrefilter(query: ParsedQuery): { clauses: string[]; args: unknown[] } {
1588
+ const clauses: string[] = []
1589
+ const args: unknown[] = []
1590
+ for (const group of query.groups) {
1591
+ const ors: string[] = []
1592
+ const groupArgs: unknown[] = []
1593
+ let expressible = true
1594
+ for (const term of group) {
1595
+ // SQLite's lower() folds ASCII only; leave such terms to the exact matcher.
1596
+ if (/[^\x00-\x7f]/.test(term.value)) { expressible = false; break }
1597
+ const column = term.field ? SENT_SEARCH_COLUMNS[term.field] : null
1598
+ if (column) {
1599
+ ors.push(`lower(coalesce(${column}, '')) ${term.negated ? 'NOT LIKE' : 'LIKE'} ? ESCAPE '\\'`)
1600
+ groupArgs.push(likeLiteral(term.value))
1601
+ continue
1602
+ }
1603
+ if (!term.field) {
1604
+ if (term.negated) { expressible = false; break }
1605
+ const columns = ['s.to_addrs', 's.cc', 's.from_addr', 's.subject', "substr(coalesce(s.body_text, ''), 1, 4000)"]
1606
+ ors.push(`(${columns.map(col => `lower(coalesce(${col}, '')) LIKE ? ESCAPE '\\'`).join(' OR ')})`)
1607
+ groupArgs.push(...columns.map(() => likeLiteral(term.value)))
1608
+ continue
1609
+ }
1610
+ if (term.field === 'before' || term.field === 'after') {
1611
+ const at = Date.parse(term.value)
1612
+ if (Number.isNaN(at)) { expressible = false; break }
1613
+ ors.push(`s.created_at ${term.field === 'before' ? '<' : '>'} ?`)
1614
+ groupArgs.push(new Date(at).toISOString())
1615
+ continue
1616
+ }
1617
+ if (term.field === 'older_than' || term.field === 'newer_than') {
1618
+ const span = duration(term.value)
1619
+ if (span == null) { expressible = false; break }
1620
+ ors.push(`s.created_at ${term.field === 'older_than' ? '<' : '>'} ?`)
1621
+ groupArgs.push(new Date(Date.now() - span).toISOString())
1622
+ continue
1623
+ }
1624
+ expressible = false
1625
+ break
1626
+ }
1627
+ if (expressible && ors.length) {
1628
+ clauses.push(`(${ors.join(' OR ')})`)
1629
+ args.push(...groupArgs)
1630
+ }
1631
+ }
1632
+ return { clauses, args }
1633
+ }
1634
+
1635
+ /**
1636
+ * The sent archive for one mailbox, newest first. Unscoped, the newest 500 rows of the
1637
+ * whole table were read and then filtered by owner, so each person saw their share of the
1638
+ * company's last 500 sends — a quiet mailbox could see a dozen. The owner is matched on
1639
+ * the assignment in mail_sent_meta, else on the sender address; the caller still applies
1640
+ * its exact attribution on top.
1641
+ *
1642
+ * With a query, the search runs here over the whole archive instead of over whatever the
1643
+ * client happened to have loaded, and returns the body text the matcher needs.
1644
+ */
1645
+ export async function readSentArchive(options: {
1646
+ ownerAddress?: string | null
1647
+ sharedAddress?: string | null
1648
+ query?: ParsedQuery | null
1649
+ limit?: number
1650
+ } = {}): Promise<SentMessage[]> {
1432
1651
  await ensureMailSchema()
1433
1652
  const sql = db()
1653
+ const limit = Math.min(Math.max(options.limit ?? 500, 1), 1000)
1654
+ const where: string[] = ['coalesce(m.is_auto, 0) = 0']
1655
+ const args: unknown[] = []
1656
+
1657
+ const owner = options.ownerAddress?.trim().toLowerCase()
1658
+ if (owner && owner !== options.sharedAddress?.toLowerCase()) {
1659
+ where.push(`(lower(m.owner) = ? OR ((m.owner IS NULL OR m.owner = '') AND lower(s.from_addr) LIKE ? ESCAPE '\\'))`)
1660
+ args.push(owner, likeLiteral(owner))
1661
+ }
1662
+ if (options.query && !options.query.isEmpty) {
1663
+ const prefilter = sentSearchPrefilter(options.query)
1664
+ where.push(...prefilter.clauses)
1665
+ args.push(...prefilter.args)
1666
+ }
1667
+
1434
1668
  // The list never shows a body, and 500 bodies is tens of megabytes — the Sent folder
1435
- // took half a minute to open once an archive had been imported.
1436
- const rows = await sql`
1437
- SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, created_at, last_event
1438
- FROM mail_sent ORDER BY created_at DESC LIMIT 500`
1669
+ // took half a minute to open once an archive had been imported. A search needs some of
1670
+ // it to decide bare terms, so it gets the first few thousand characters and no more.
1671
+ const textColumn = options.query ? "substr(coalesce(s.body_text, ''), 1, 4000)" : 'NULL'
1672
+ const rows = await tagged(
1673
+ sql,
1674
+ `SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
1675
+ ${textColumn} AS text
1676
+ FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
1677
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
1678
+ ORDER BY s.created_at DESC LIMIT ?`,
1679
+ [...args, limit],
1680
+ )
1439
1681
  return rows.map(row => ({
1440
1682
  id: String(row.id),
1441
1683
  from: (row.from_addr as string) ?? '',
@@ -1445,7 +1687,7 @@ export async function readSentArchive(): Promise<SentMessage[]> {
1445
1687
  replyTo: parseArray(row.reply_to),
1446
1688
  subject: (row.subject as string) ?? '',
1447
1689
  html: null,
1448
- text: null,
1690
+ text: row.text == null ? null : String(row.text),
1449
1691
  createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1450
1692
  lastEvent: (row.last_event as string) ?? null,
1451
1693
  }))
@@ -1512,17 +1754,33 @@ export async function readSentMeta(emailIds?: string[]): Promise<Record<string,
1512
1754
  // The caller only ever needs rows for the messages it is about to return. Unscoped,
1513
1755
  // this read the whole table — thirty-odd thousand rows — on every poll of every tab,
1514
1756
  // which is most of a month's read quota on its own.
1515
- const safe = (emailIds ?? []).filter(id => /^[A-Za-z0-9._:+@-]{1,200}$/.test(id))
1516
- const scope = emailIds ? ` WHERE email_id IN (${safe.map(id => `'${id}'`).join(',') || "''"})` : ''
1517
- const rows = await sql`SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta${scope}`
1757
+ // The sql tag binds every interpolation as a parameter; a spliced WHERE became `... ?`,
1758
+ // a syntax error the callers swallowed, so every lookup came back empty.
1518
1759
  const out: Record<string, SentMeta> = {}
1519
- for (const row of rows) {
1520
- out[String(row.email_id)] = {
1521
- owner: (row.owner as string) ?? null,
1522
- isAuto: Boolean(row.is_auto),
1523
- inReplyTo: (row.in_reply_to as string) ?? null,
1760
+ const collect = (rows: Record<string, unknown>[]) => {
1761
+ for (const row of rows) {
1762
+ out[String(row.email_id)] = {
1763
+ owner: (row.owner as string) ?? null,
1764
+ isAuto: Boolean(row.is_auto),
1765
+ inReplyTo: (row.in_reply_to as string) ?? null,
1766
+ }
1524
1767
  }
1525
1768
  }
1769
+ if (!emailIds) {
1770
+ collect(await sql`SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta`)
1771
+ return out
1772
+ }
1773
+ for (let i = 0; i < emailIds.length; i += 200) {
1774
+ const chunk = emailIds.slice(i, i + 200)
1775
+ if (!chunk.length) break
1776
+ collect(
1777
+ await tagged(
1778
+ sql,
1779
+ `SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta WHERE email_id IN (${chunk.map(() => '?').join(',')})`,
1780
+ chunk,
1781
+ ),
1782
+ )
1783
+ }
1526
1784
  return out
1527
1785
  }
1528
1786
 
@@ -1686,6 +1944,9 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
1686
1944
  await sql`
1687
1945
  INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
1688
1946
  ON CONFLICT (email) DO UPDATE SET password_hash = excluded.password_hash, status = 'active'`
1947
+ // Any other link still outstanding for this address is now stale — and one of them
1948
+ // may be the reason the password is being changed.
1949
+ await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
1689
1950
  return String(email)
1690
1951
  }
1691
1952
 
@@ -1731,8 +1992,16 @@ export async function deleteStash(owner: string, kind: string, id: string): Prom
1731
1992
  */
1732
1993
  const CLAIM_LEASE_MS = 10 * 60 * 1000
1733
1994
 
1734
- export async function claimWebhookEvent(id: string): Promise<boolean> {
1735
- if (!id) return true
1995
+ /**
1996
+ * "busy" is the one that matters: an attempt is still inside its lease. The caller must
1997
+ * answer the provider with a failure, not a duplicate — five stranded deliveries on the
1998
+ * old host came from a process the platform killed mid-way, whose 5s and 5m retries were
1999
+ * each told "already handled" and so the provider never tried again.
2000
+ */
2001
+ export type WebhookClaim = 'claimed' | 'done' | 'busy'
2002
+
2003
+ export async function claimWebhookEvent(id: string): Promise<WebhookClaim> {
2004
+ if (!id) return 'claimed'
1736
2005
  await ensureMailSchema()
1737
2006
 
1738
2007
  const inserted = await sqlRaw(
@@ -1741,19 +2010,19 @@ export async function claimWebhookEvent(id: string): Promise<boolean> {
1741
2010
  RETURNING id`,
1742
2011
  [id, nowIso()],
1743
2012
  )
1744
- if (inserted.length > 0) return true
2013
+ if (inserted.length > 0) return 'claimed'
1745
2014
 
1746
2015
  const existing = await sqlRaw('SELECT handled_at, status FROM mail_webhook_events WHERE id = ?', [id])
1747
2016
  const row = existing[0]
1748
- if (!row) return true
1749
- if (String(row.status) === 'done') return false
2017
+ if (!row) return 'claimed'
2018
+ if (String(row.status) === 'done') return 'done'
1750
2019
 
1751
2020
  const startedAt = new Date(String(row.handled_at)).getTime()
1752
- if (Number.isFinite(startedAt) && Date.now() - startedAt < CLAIM_LEASE_MS) return false
2021
+ if (Number.isFinite(startedAt) && Date.now() - startedAt < CLAIM_LEASE_MS) return 'busy'
1753
2022
 
1754
2023
  // The attempt holding this never finished and its lease has run out. Take it over.
1755
2024
  await sqlRaw('UPDATE mail_webhook_events SET handled_at = ? WHERE id = ?', [nowIso(), id])
1756
- return true
2025
+ return 'claimed'
1757
2026
  }
1758
2027
 
1759
2028
  /** Marks the delivery finished, so later redeliveries of it are refused for good. */