@novacraft-engineering/mailbox 0.1.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 (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
package/lib/mailbox.ts ADDED
@@ -0,0 +1,1888 @@
1
+ import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
2
+ /**
3
+ * Durable mail state on Cloudflare D1 (SQLite). Inbox, delivery events, account
4
+ * credentials, reset tokens, and a per-account stash for drafts + saved templates.
5
+ *
6
+ * SQLite differences that matter here: booleans are 0/1, JSON columns are TEXT and come
7
+ * back as strings (Postgres jsonb arrived pre-parsed), and timestamps are ISO strings
8
+ * supplied by the app rather than `now()`.
9
+ */
10
+
11
+ import { d1, d1Batch, d1Query } from './d1'
12
+ import { stripCidPlaceholders } from './email-html'
13
+ import { hashPassword } from './password'
14
+ import { turso, tursoBatch, tursoQuery } from './turso'
15
+ import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
16
+
17
+ export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
18
+
19
+ export type InboundEmail = {
20
+ id: string
21
+ from: string
22
+ to: string[]
23
+ cc: string[]
24
+ bcc: string[]
25
+ replyTo: string[]
26
+ subject: string
27
+ html: string | null
28
+ text: string | null
29
+ headers: Record<string, unknown>
30
+ receivedAt: string
31
+ read: boolean
32
+ attachments: InboundAttachment[]
33
+ starred: boolean
34
+ archived: boolean
35
+ trashed: boolean
36
+ labels: string[]
37
+ owner: string | null
38
+ threadId: string | null
39
+ }
40
+
41
+ export type InboundFlags = Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>
42
+
43
+ export type Contact = { email: string; name: string | null }
44
+
45
+ export type MailEvent = {
46
+ emailId: string
47
+ type: string
48
+ at: string
49
+ meta?: Record<string, string>
50
+ }
51
+
52
+ export type StashItem = {
53
+ id: string
54
+ data: unknown
55
+ updatedAt: string
56
+ }
57
+
58
+ const MAX_INBOX = 500
59
+ const MAX_EVENTS = 150
60
+
61
+ /** Turso once it is configured, D1 until then, so the switch needs no redeploy dance. */
62
+ const tursoConfigured = () => Boolean(process.env.TURSO_DATABASE_URL)
63
+
64
+ function db() {
65
+ return tursoConfigured() ? turso() : d1()
66
+ }
67
+
68
+ /** Raw SQL with positional args, for queries whose shape is built at runtime. */
69
+ function tagged(_sql: unknown, text: string, args: unknown[]): Promise<Record<string, unknown>[]> {
70
+ return tursoConfigured() ? tursoQuery(text, args) : d1Query(text, args)
71
+ }
72
+
73
+ function sqlRaw(text: string, args: unknown[] = []): Promise<Record<string, unknown>[]> {
74
+ return tursoConfigured() ? tursoQuery(text, args) : d1Query(text, args)
75
+ }
76
+
77
+ function dbBatch(statements: string[]): Promise<void> {
78
+ return tursoConfigured() ? tursoBatch(statements) : d1Batch(statements)
79
+ }
80
+
81
+ function nowIso(): string {
82
+ return new Date().toISOString()
83
+ }
84
+
85
+ /** JSON columns are TEXT in SQLite — tolerate already-parsed values and bad data. */
86
+ function parseJson<T>(value: unknown, fallback: T): T {
87
+ if (value === null || value === undefined) return fallback
88
+ if (typeof value === 'object') return value as T
89
+ if (typeof value !== 'string') return fallback
90
+ try {
91
+ const parsed = JSON.parse(value)
92
+ return (parsed ?? fallback) as T
93
+ } catch {
94
+ return fallback
95
+ }
96
+ }
97
+
98
+ function parseArray(value: unknown): string[] {
99
+ const parsed = parseJson<unknown>(value, [])
100
+ return Array.isArray(parsed) ? parsed.map(String) : []
101
+ }
102
+
103
+ function isoOrNull(value: unknown): string | null {
104
+ if (!value) return null
105
+ const date = new Date(String(value))
106
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
107
+ }
108
+
109
+ /**
110
+ * Create the schema on first use. D1 has no migration runner either, but unlike the
111
+ * Postgres original we own the full CREATE, so there are no incremental ALTERs to replay.
112
+ * Memoized per server instance.
113
+ */
114
+ let schemaReady: Promise<void> | null = null
115
+ export function ensureMailSchema(): Promise<void> {
116
+ if (!schemaReady) {
117
+ schemaReady = (async () => {
118
+ await dbBatch([
119
+ `CREATE TABLE IF NOT EXISTS mail_inbox (
120
+ id TEXT PRIMARY KEY,
121
+ from_addr TEXT,
122
+ to_addrs TEXT,
123
+ cc TEXT,
124
+ bcc TEXT,
125
+ reply_to TEXT,
126
+ subject TEXT,
127
+ html TEXT,
128
+ body_text TEXT,
129
+ headers TEXT,
130
+ received_at TEXT,
131
+ read INTEGER NOT NULL DEFAULT 0,
132
+ attachments TEXT,
133
+ starred INTEGER NOT NULL DEFAULT 0,
134
+ archived INTEGER NOT NULL DEFAULT 0,
135
+ trashed INTEGER NOT NULL DEFAULT 0,
136
+ labels TEXT,
137
+ owner TEXT
138
+ )`,
139
+ `CREATE INDEX IF NOT EXISTS mail_inbox_received_idx ON mail_inbox (received_at DESC)`,
140
+ `CREATE INDEX IF NOT EXISTS mail_inbox_owner_idx ON mail_inbox (lower(owner))`,
141
+ // The list always filters by mailbox and folder and sorts by date. Without an
142
+ // index carrying all three, SQLite reads every matching row into a temp B-tree to
143
+ // sort it and then discards all but one page.
144
+ `CREATE INDEX IF NOT EXISTS mail_inbox_list_idx ON mail_inbox (lower(owner), archived, trashed, received_at DESC, id DESC)`,
145
+ `CREATE INDEX IF NOT EXISTS mail_inbox_owner_recent_idx ON mail_inbox (lower(owner), received_at DESC, id DESC)`,
146
+ // The folder counts read only these five columns. Without them all in one index
147
+ // SQLite walks the rows themselves, and a row here can carry 50KB of html — which
148
+ // turned a five-number summary into a 37-second scan on the larger mailboxes.
149
+ `CREATE INDEX IF NOT EXISTS mail_inbox_counts_idx ON mail_inbox (lower(owner), archived, trashed, read, starred)`,
150
+ `CREATE TABLE IF NOT EXISTS mail_threads (
151
+ owner TEXT NOT NULL,
152
+ thread_id TEXT NOT NULL,
153
+ subject_key TEXT NOT NULL,
154
+ subject TEXT,
155
+ first_at TEXT NOT NULL,
156
+ latest_at TEXT NOT NULL,
157
+ latest_id TEXT,
158
+ count INTEGER NOT NULL DEFAULT 0,
159
+ unread_count INTEGER NOT NULL DEFAULT 0,
160
+ starred_count INTEGER NOT NULL DEFAULT 0,
161
+ inbox_count INTEGER NOT NULL DEFAULT 0,
162
+ archived_count INTEGER NOT NULL DEFAULT 0,
163
+ trashed_count INTEGER NOT NULL DEFAULT 0,
164
+ attach_count INTEGER NOT NULL DEFAULT 0,
165
+ senders TEXT,
166
+ snippet TEXT,
167
+ labels TEXT,
168
+ PRIMARY KEY (owner, thread_id)
169
+ )`,
170
+ `CREATE INDEX IF NOT EXISTS mail_threads_latest_idx ON mail_threads (owner, latest_at DESC)`,
171
+ `CREATE INDEX IF NOT EXISTS mail_threads_key_idx ON mail_threads (owner, subject_key, latest_at DESC)`,
172
+ `CREATE INDEX IF NOT EXISTS mail_threads_list_idx ON mail_threads (owner, latest_at DESC, thread_id DESC)`,
173
+ `CREATE INDEX IF NOT EXISTS mail_inbox_folder_idx ON mail_inbox (archived, trashed, received_at DESC, id DESC)`,
174
+ `CREATE INDEX IF NOT EXISTS mail_inbox_starred_idx ON mail_inbox (starred, trashed, received_at DESC, id DESC)`,
175
+ // Finds rows whose body still sits in the database. Partial, so it shrinks to
176
+ // nothing as bodies move to the bucket instead of growing with the mailbox.
177
+ `CREATE INDEX IF NOT EXISTS mail_inbox_html_pending_idx ON mail_inbox (id) WHERE html IS NOT NULL AND html != ''`,
178
+ // password_hash is nullable: invited accounts exist before a password is set.
179
+ `CREATE TABLE IF NOT EXISTS mail_accounts (
180
+ email TEXT PRIMARY KEY,
181
+ password_hash TEXT,
182
+ role TEXT NOT NULL DEFAULT 'member',
183
+ name TEXT,
184
+ address TEXT,
185
+ status TEXT NOT NULL DEFAULT 'active',
186
+ created_at TEXT,
187
+ invited_by TEXT
188
+ )`,
189
+ `CREATE UNIQUE INDEX IF NOT EXISTS mail_accounts_address_key ON mail_accounts (lower(address)) WHERE address IS NOT NULL`,
190
+ `CREATE TABLE IF NOT EXISTS mail_sent_flags (
191
+ email_id TEXT PRIMARY KEY,
192
+ starred INTEGER NOT NULL DEFAULT 0,
193
+ archived INTEGER NOT NULL DEFAULT 0,
194
+ trashed INTEGER NOT NULL DEFAULT 0,
195
+ updated_at TEXT
196
+ )`,
197
+ // Full copies of what we've sent. The Sent folder used to read live from the
198
+ // provider, which meant the history existed nowhere else — this makes it ours.
199
+ `CREATE TABLE IF NOT EXISTS mail_sent (
200
+ id TEXT PRIMARY KEY,
201
+ from_addr TEXT,
202
+ to_addrs TEXT,
203
+ cc TEXT,
204
+ bcc TEXT,
205
+ reply_to TEXT,
206
+ subject TEXT,
207
+ html TEXT,
208
+ body_text TEXT,
209
+ created_at TEXT,
210
+ last_event TEXT,
211
+ provider TEXT,
212
+ archived_at TEXT,
213
+ attachments TEXT
214
+ )`,
215
+ `CREATE INDEX IF NOT EXISTS mail_sent_created_idx ON mail_sent (created_at DESC)`,
216
+ // Covers the Sent list query. Bodies sit before created_at in the row, so without
217
+ // this SQLite walks every message's overflow pages just to read its date.
218
+ `CREATE INDEX IF NOT EXISTS mail_sent_list_idx ON mail_sent (created_at DESC, id, from_addr, to_addrs, cc, bcc, reply_to, subject, last_event)`,
219
+ `CREATE TABLE IF NOT EXISTS mail_sent_meta (
220
+ email_id TEXT PRIMARY KEY,
221
+ owner TEXT,
222
+ is_auto INTEGER NOT NULL DEFAULT 0,
223
+ created_at TEXT,
224
+ in_reply_to TEXT
225
+ )`,
226
+ `CREATE TABLE IF NOT EXISTS mail_pixels (
227
+ pixel_id TEXT PRIMARY KEY,
228
+ email_id TEXT,
229
+ recipient TEXT,
230
+ subject TEXT,
231
+ open_count INTEGER NOT NULL DEFAULT 0,
232
+ opened_at TEXT,
233
+ created_at TEXT
234
+ )`,
235
+ `CREATE INDEX IF NOT EXISTS mail_pixels_email_idx ON mail_pixels (email_id)`,
236
+ `CREATE TABLE IF NOT EXISTS mail_contacts (
237
+ email TEXT PRIMARY KEY,
238
+ name TEXT,
239
+ seen_count INTEGER NOT NULL DEFAULT 1,
240
+ last_seen TEXT
241
+ )`,
242
+ `CREATE TABLE IF NOT EXISTS mail_push_subscriptions (
243
+ endpoint TEXT PRIMARY KEY,
244
+ owner TEXT NOT NULL,
245
+ subscription TEXT NOT NULL,
246
+ created_at TEXT
247
+ )`,
248
+ `CREATE INDEX IF NOT EXISTS mail_push_owner_idx ON mail_push_subscriptions (owner)`,
249
+ `CREATE TABLE IF NOT EXISTS mail_settings (
250
+ owner TEXT PRIMARY KEY,
251
+ data TEXT
252
+ )`,
253
+ `CREATE TABLE IF NOT EXISTS mail_events (
254
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
255
+ email_id TEXT,
256
+ type TEXT,
257
+ at TEXT,
258
+ meta TEXT
259
+ )`,
260
+ `CREATE INDEX IF NOT EXISTS mail_events_at_idx ON mail_events (at DESC)`,
261
+ // Large attachments live in object storage; this is the record that lets
262
+ // a recipient claim one, and the only place the password hash is kept.
263
+ `CREATE TABLE IF NOT EXISTS mail_shares (
264
+ id TEXT PRIMARY KEY,
265
+ object_key TEXT NOT NULL,
266
+ filename TEXT NOT NULL,
267
+ content_type TEXT,
268
+ size INTEGER NOT NULL DEFAULT 0,
269
+ password_hash TEXT,
270
+ owner TEXT,
271
+ created_at TEXT,
272
+ expires_at TEXT,
273
+ downloads INTEGER NOT NULL DEFAULT 0,
274
+ max_downloads INTEGER,
275
+ revoked INTEGER NOT NULL DEFAULT 0
276
+ )`,
277
+ `CREATE INDEX IF NOT EXISTS mail_shares_owner_idx ON mail_shares (owner, created_at DESC)`,
278
+ // Folder counts walk the whole mailbox index and Turso meters every entry; an
279
+ // in-memory cache dies with the instance, and instances churn under polling. One
280
+ // row here outlives them all.
281
+ `CREATE TABLE IF NOT EXISTS mail_counts_cache (
282
+ owner TEXT PRIMARY KEY,
283
+ computed_at TEXT NOT NULL,
284
+ counts TEXT NOT NULL
285
+ )`,
286
+ // Full-text search belongs in the database, not the browser. The client used to
287
+ // pull the mailbox down and filter it in JS, which cannot hold once the archive
288
+ // runs to six figures.
289
+ `CREATE VIRTUAL TABLE IF NOT EXISTS mail_inbox_fts USING fts5(
290
+ subject, body_text, from_addr, to_addrs,
291
+ content='mail_inbox', content_rowid='rowid', tokenize='porter unicode61'
292
+ )`,
293
+ `CREATE TRIGGER IF NOT EXISTS mail_inbox_fts_ai AFTER INSERT ON mail_inbox BEGIN
294
+ INSERT INTO mail_inbox_fts(rowid, subject, body_text, from_addr, to_addrs)
295
+ VALUES (new.rowid, new.subject, new.body_text, new.from_addr, new.to_addrs);
296
+ END`,
297
+ `CREATE TRIGGER IF NOT EXISTS mail_inbox_fts_ad AFTER DELETE ON mail_inbox BEGIN
298
+ INSERT INTO mail_inbox_fts(mail_inbox_fts, rowid, subject, body_text, from_addr, to_addrs)
299
+ VALUES ('delete', old.rowid, old.subject, old.body_text, old.from_addr, old.to_addrs);
300
+ END`,
301
+ // Flags change constantly and the indexed text almost never does; re-tokenising
302
+ // a whole body to mark it read was metered work for nothing.
303
+ `CREATE TRIGGER IF NOT EXISTS mail_inbox_fts_au AFTER UPDATE ON mail_inbox
304
+ WHEN old.subject IS NOT new.subject OR old.body_text IS NOT new.body_text
305
+ OR old.from_addr IS NOT new.from_addr OR old.to_addrs IS NOT new.to_addrs
306
+ BEGIN
307
+ INSERT INTO mail_inbox_fts(mail_inbox_fts, rowid, subject, body_text, from_addr, to_addrs)
308
+ VALUES ('delete', old.rowid, old.subject, old.body_text, old.from_addr, old.to_addrs);
309
+ INSERT INTO mail_inbox_fts(rowid, subject, body_text, from_addr, to_addrs)
310
+ VALUES (new.rowid, new.subject, new.body_text, new.from_addr, new.to_addrs);
311
+ END`,
312
+ `CREATE INDEX IF NOT EXISTS mail_inbox_received_idx ON mail_inbox (received_at DESC)`,
313
+ `CREATE TABLE IF NOT EXISTS mail_reset_tokens (
314
+ token TEXT PRIMARY KEY,
315
+ email TEXT,
316
+ expires_at TEXT
317
+ )`,
318
+ `CREATE TABLE IF NOT EXISTS mail_stash (
319
+ owner TEXT,
320
+ kind TEXT,
321
+ id TEXT,
322
+ data TEXT,
323
+ updated_at TEXT,
324
+ PRIMARY KEY (owner, kind, id)
325
+ )`,
326
+ `CREATE TABLE IF NOT EXISTS mail_webhook_events (
327
+ id TEXT PRIMARY KEY,
328
+ handled_at TEXT,
329
+ status TEXT NOT NULL DEFAULT 'working'
330
+ )`,
331
+ `CREATE INDEX IF NOT EXISTS mail_webhook_events_age_idx ON mail_webhook_events (handled_at)`,
332
+ ])
333
+
334
+ // SQLite has no ADD COLUMN IF NOT EXISTS, so this runs on its own and is
335
+ // allowed to fail: the second time round the column is already there.
336
+ await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN password_is_default INTEGER NOT NULL DEFAULT 0')
337
+ .catch(() => {})
338
+ await sqlRaw("ALTER TABLE mail_webhook_events ADD COLUMN status TEXT NOT NULL DEFAULT 'working'")
339
+ .catch(() => {})
340
+ await sqlRaw('ALTER TABLE mail_sent ADD COLUMN attachments TEXT').catch(() => {})
341
+
342
+ // Drawing a list row needed the whole message: 24KB of body to show 320 characters
343
+ // of it, 5KB of headers to read three of them, and the attachment list to learn
344
+ // whether there was one. These hold just those answers.
345
+ await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN snippet TEXT').catch(() => {})
346
+ await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN thread_meta TEXT').catch(() => {})
347
+ await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN attach_meta TEXT').catch(() => {})
348
+ // thread_id arrives by migration, so its index has to follow the column rather than
349
+ // sit in the batch above, where a fresh database has no such column yet.
350
+ await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN thread_id TEXT').catch(() => {})
351
+ 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
+
353
+ // Seed the mailboxes this deployment serves — metadata only, never clobber an
354
+ // existing password_hash.
355
+ const sql = db()
356
+ for (const seat of MAIL_SEATS) {
357
+ // Every seat starts with its own address as the password, stored hashed like
358
+ // any other, and flagged so the interface can ask them to change it. An
359
+ // account that already has a password of its own is never overwritten.
360
+ const seeded = await hashPassword(seat.email)
361
+ await sql`
362
+ INSERT INTO mail_accounts (email, role, name, address, status, created_at, password_hash, password_is_default)
363
+ VALUES (${seat.email}, ${seat.role}, ${seat.name}, ${seat.address}, 'active', ${nowIso()}, ${seeded}, 1)
364
+ ON CONFLICT (email) DO UPDATE SET
365
+ status = 'active',
366
+ role = COALESCE(mail_accounts.role, excluded.role),
367
+ name = COALESCE(mail_accounts.name, excluded.name),
368
+ address = COALESCE(mail_accounts.address, excluded.address),
369
+ password_hash = COALESCE(mail_accounts.password_hash, excluded.password_hash),
370
+ password_is_default = CASE
371
+ WHEN mail_accounts.password_hash IS NULL THEN 1
372
+ ELSE mail_accounts.password_is_default END`
373
+ }
374
+ })().catch(err => {
375
+ // Reset so a transient failure can retry on the next call instead of caching a rejection.
376
+ schemaReady = null
377
+ throw err
378
+ })
379
+ }
380
+ return schemaReady
381
+ }
382
+
383
+ // ── Inbox ──────────────────────────────────────────────────────
384
+ function mapInbound(row: Record<string, unknown>): InboundEmail {
385
+ return {
386
+ id: String(row.id),
387
+ from: (row.from_addr as string) ?? '',
388
+ to: parseArray(row.to_addrs),
389
+ cc: parseArray(row.cc),
390
+ bcc: parseArray(row.bcc),
391
+ replyTo: parseArray(row.reply_to),
392
+ subject: (row.subject as string) ?? '',
393
+ html: (row.html as string) ?? null,
394
+ text: (row.body_text as string) ?? null,
395
+ headers: parseJson<Record<string, unknown>>(row.headers, {}),
396
+ receivedAt: isoOrNull(row.received_at) ?? new Date(0).toISOString(),
397
+ read: Boolean(row.read),
398
+ attachments: parseJson<InboundAttachment[]>(row.attachments, []),
399
+ starred: Boolean(row.starred),
400
+ archived: Boolean(row.archived),
401
+ trashed: Boolean(row.trashed),
402
+ labels: parseArray(row.labels),
403
+ owner: (row.owner as string) ?? null,
404
+ threadId: row.thread_id == null ? null : String(row.thread_id),
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Server-side inbox query. Free text goes to the FTS5 index; the structured operators
410
+ * (is:, has:, label:, in:, from:, to:) become SQL predicates. Returns a page, not the
411
+ * whole mailbox — 140k messages cannot be filtered in the browser.
412
+ */
413
+ export type InboxPage = {
414
+ rows: InboundEmail[]
415
+ total: number | null
416
+ nextCursor: string | null
417
+ }
418
+
419
+ /**
420
+ * One page of a mailbox.
421
+ *
422
+ * Two things keep this fast on an archive of this size. The row carries a short snippet
423
+ * rather than its body — the list only ever renders one line of it, and shipping whole
424
+ * bodies cost half a megabyte a page. And paging is by cursor rather than offset, so page
425
+ * fifty costs the same as page one instead of re-reading and discarding everything above it.
426
+ */
427
+ export async function searchInbox(options: {
428
+ text?: string
429
+ owner?: string
430
+ folder?: 'inbox' | 'archive' | 'trash' | 'starred'
431
+ unread?: boolean
432
+ starred?: boolean
433
+ hasAttachment?: boolean
434
+ label?: string
435
+ from?: string
436
+ to?: string
437
+ limit?: number
438
+ offset?: number
439
+ cursor?: string | null
440
+ withTotal?: boolean
441
+ threadId?: string
442
+ }): Promise<InboxPage> {
443
+ await ensureMailSchema()
444
+ const sql = db()
445
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200)
446
+ const offset = Math.max(options.offset ?? 0, 0)
447
+
448
+ const where: string[] = []
449
+ let ftsFrom = ''
450
+ const args: unknown[] = []
451
+
452
+ const text = options.text?.trim()
453
+ if (text) {
454
+ // Quote each term so a stray operator character cannot break FTS5 syntax.
455
+ const match = text.split(/\s+/).filter(Boolean).map(term => `"${term.replace(/"/g, '""')}"`).join(' ')
456
+ // Unbounded, every full-text hit went to the outer query, which then walked the whole
457
+ // mailbox checking rows against them; a poll re-running that every tick was most of a
458
+ // month's read quota. Joining the best three thousand by rank makes the match drive the
459
+ // query, so it probes those rows and no others.
460
+ ftsFrom = '(SELECT rowid AS fts_rid FROM mail_inbox_fts WHERE mail_inbox_fts MATCH ? ORDER BY rank LIMIT 3000) fts CROSS JOIN '
461
+ args.push(match)
462
+ }
463
+ if (options.owner) { where.push('lower(m.owner) = ?'); args.push(options.owner.toLowerCase()) }
464
+ if (options.threadId) { where.push('m.thread_id = ?'); args.push(options.threadId) }
465
+ if (options.unread !== undefined) { where.push('m.read = ?'); args.push(options.unread ? 0 : 1) }
466
+ if (options.starred !== undefined) { where.push('m.starred = ?'); args.push(options.starred ? 1 : 0) }
467
+ if (options.hasAttachment) where.push("m.attachments IS NOT NULL AND m.attachments NOT IN ('', '[]')")
468
+ if (options.label) { where.push('m.labels LIKE ?'); args.push(`%${options.label}%`) }
469
+ if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
470
+ if (options.to) { where.push('lower(m.to_addrs) LIKE ?'); args.push(`%${options.to.toLowerCase()}%`) }
471
+
472
+ if (options.folder === 'trash') where.push('m.trashed = 1')
473
+ else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0')
474
+ 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')
476
+
477
+ const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
478
+
479
+ // The cursor is the last row of the previous page; ordering by (date, id) keeps it
480
+ // stable when several messages share a timestamp.
481
+ const pageWhere = [...where]
482
+ const pageArgs = [...args]
483
+ const cursor = decodeCursor(options.cursor)
484
+ if (cursor) {
485
+ pageWhere.push('(m.received_at, m.id) < (?, ?)')
486
+ pageArgs.push(cursor.receivedAt, cursor.id)
487
+ }
488
+ const pageClause = pageWhere.length ? `WHERE ${pageWhere.join(' AND ')}` : ''
489
+
490
+ const rows = await tagged(
491
+ sql,
492
+ `SELECT m.id, m.from_addr, m.to_addrs, m.cc, m.bcc, m.reply_to, m.subject,
493
+ COALESCE(m.snippet, substr(COALESCE(m.body_text, ''), 1, 320)) AS snippet,
494
+ COALESCE(m.thread_meta, json_object(
495
+ 'message-id', COALESCE(json_extract(m.headers, '$."message-id"'), ''),
496
+ 'in-reply-to', COALESCE(json_extract(m.headers, '$."in-reply-to"'), ''),
497
+ 'references', COALESCE(json_extract(m.headers, '$."references"'), ''))) AS headers,
498
+ m.received_at, m.read,
499
+ COALESCE(m.attach_meta, (
500
+ SELECT json_group_array(json_object(
501
+ 'filename', json_extract(value, '$.filename'),
502
+ 'contentType', json_extract(value, '$.contentType'),
503
+ 'size', json_extract(value, '$.size')))
504
+ 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
506
+ FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
507
+ ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
508
+ cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
509
+ )
510
+
511
+ // Counting scans the whole match, so it runs only for the first page; later pages
512
+ // reuse the figure the client already holds.
513
+ let total: number | null = null
514
+ if (options.withTotal !== false && !cursor && offset === 0) {
515
+ const counted = await tagged(sql, `SELECT COUNT(*) AS n FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${filterClause}`, args)
516
+ total = Number((counted[0]?.n as number) ?? 0)
517
+ }
518
+
519
+ const mapped = rows.map(row => ({
520
+ ...mapInbound({ ...row, body_text: stripCidPlaceholders(String(row.snippet ?? '')), html: null }),
521
+ }))
522
+ const last = rows[rows.length - 1]
523
+ const nextCursor =
524
+ rows.length === limit && last
525
+ ? encodeCursor(String(last.received_at ?? ''), String(last.id))
526
+ : null
527
+
528
+ return { rows: mapped, total, nextCursor }
529
+ }
530
+
531
+ function encodeCursor(receivedAt: string, id: string): string {
532
+ return Buffer.from(`${receivedAt}|${id}`, 'utf8').toString('base64url')
533
+ }
534
+
535
+ function decodeCursor(value?: string | null): { receivedAt: string; id: string } | null {
536
+ if (!value) return null
537
+ try {
538
+ const [receivedAt, id] = Buffer.from(value, 'base64url').toString('utf8').split('|')
539
+ return receivedAt && id ? { receivedAt, id } : null
540
+ } catch {
541
+ return null
542
+ }
543
+ }
544
+
545
+ export type FolderCounts = {
546
+ inbox: number
547
+ unread: number
548
+ starred: number
549
+ archived: number
550
+ trashed: number
551
+ }
552
+
553
+ /**
554
+ * Folder totals for a whole mailbox.
555
+ *
556
+ * These have to come from the database. Counting the rows the browser happens to hold
557
+ * describes the current page rather than the mailbox, and the figure climbs as you scroll
558
+ * — and because the list groups messages into conversations while unread counts messages,
559
+ * the two were not even in the same units. Every clause here is served by the list indexes.
560
+ */
561
+ const COUNTS_CACHE_MS = 5 * 60 * 1000
562
+
563
+ /** countFolders through a durable cache: one row read when fresh, a full scan only when stale. */
564
+ export async function invalidateCounts(owner: string | null | undefined): Promise<void> {
565
+ if (!owner) return
566
+ try {
567
+ await db()`DELETE FROM mail_counts_cache WHERE owner = ${owner.toLowerCase()}`
568
+ } catch {
569
+ }
570
+ }
571
+
572
+ export async function countFoldersCached(owner: string): Promise<FolderCounts> {
573
+ await ensureMailSchema()
574
+ const sql = db()
575
+ const key = owner.toLowerCase()
576
+ const hit = await sql`SELECT computed_at, counts FROM mail_counts_cache WHERE owner = ${key}`
577
+ const row = hit[0]
578
+ if (row && Date.now() - Date.parse(String(row.computed_at)) < COUNTS_CACHE_MS) {
579
+ const parsed = parseJson<FolderCounts | null>(row.counts, null)
580
+ if (parsed) return parsed
581
+ }
582
+ const fresh = await countFolders(owner)
583
+ await sql`
584
+ INSERT INTO mail_counts_cache (owner, computed_at, counts)
585
+ VALUES (${key}, ${new Date().toISOString()}, ${JSON.stringify(fresh)})
586
+ ON CONFLICT (owner) DO UPDATE SET computed_at = excluded.computed_at, counts = excluded.counts`
587
+ return fresh
588
+ }
589
+
590
+ export async function countFolders(owner?: string): Promise<FolderCounts> {
591
+ await ensureMailSchema()
592
+ const sql = db()
593
+ const scope = owner ? 'WHERE lower(owner) = ?' : ''
594
+ const args = owner ? [owner.toLowerCase()] : []
595
+
596
+ // One pass, not one per folder: five separate counts read the table five times
597
+ // (206k rows against 69k here) for figures that all come off the same scan.
598
+ const rows = await tagged(
599
+ sql,
600
+ `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,
603
+ SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
604
+ 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
606
+ FROM mail_inbox ${scope}`,
607
+ args,
608
+ )
609
+ const row = rows[0] ?? {}
610
+ const value = (key: string) => Number((row[key] as number) ?? 0)
611
+ return {
612
+ inbox: value('inbox'),
613
+ unread: value('unread'),
614
+ starred: value('starred'),
615
+ archived: value('archived'),
616
+ trashed: value('trashed'),
617
+ }
618
+ }
619
+
620
+ export async function readInbox(filter?: { owner?: string }): Promise<InboundEmail[]> {
621
+ await ensureMailSchema()
622
+ const sql = db()
623
+ const owner = filter?.owner?.trim().toLowerCase()
624
+ const rows = owner
625
+ ? await sql`
626
+ SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id
627
+ FROM mail_inbox WHERE lower(owner) = ${owner} ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
628
+ : await sql`
629
+ SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id
630
+ FROM mail_inbox ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
631
+ return rows.map(mapInbound)
632
+ }
633
+
634
+
635
+ export type ThreadRow = {
636
+ threadId: string
637
+ subject: string
638
+ firstAt: string
639
+ latestAt: string
640
+ latestId: string | null
641
+ count: number
642
+ unreadCount: number
643
+ starredCount: number
644
+ inboxCount: number
645
+ archivedCount: number
646
+ trashedCount: number
647
+ attachCount: number
648
+ senders: string[]
649
+ snippet: string
650
+ labels: string[]
651
+ }
652
+
653
+ /**
654
+ * Pick or create the thread for a message. A thread is the same normalised subject within
655
+ * a thirty-day silence; the lookup is one indexed read on (owner, subject_key).
656
+ */
657
+ export async function assignThread(row: { id: string; owner: string; subject: string; receivedAt: string }): Promise<string> {
658
+ const sql = db()
659
+ const owner = row.owner.toLowerCase()
660
+ const key = subjectKey(row.subject ?? '')
661
+ if (!key) return threadIdFor('', row.receivedAt, row.id)
662
+ const at = Date.parse(row.receivedAt)
663
+ const candidates = await sql`
664
+ SELECT thread_id, first_at, latest_at FROM mail_threads
665
+ WHERE owner = ${owner} AND subject_key = ${key}
666
+ ORDER BY latest_at DESC LIMIT 3`
667
+ for (const candidate of candidates) {
668
+ const first = Date.parse(String(candidate.first_at))
669
+ const latest = Date.parse(String(candidate.latest_at))
670
+ // Within the gap after the newest, or (backfill arriving out of order) before the oldest.
671
+ if ((at >= first && at - latest <= THREAD_GAP_MS) || (at < first && first - at <= THREAD_GAP_MS)) {
672
+ return String(candidate.thread_id)
673
+ }
674
+ }
675
+ return threadIdFor(key, row.receivedAt, row.id)
676
+ }
677
+
678
+ /** Recompute one thread's summary from its members. Idempotent, so every write path can call it. */
679
+ export async function refreshThread(ownerRaw: string, threadId: string): Promise<void> {
680
+ const sql = db()
681
+ const owner = ownerRaw.toLowerCase()
682
+ const agg = await sql`
683
+ SELECT COUNT(*) AS n,
684
+ SUM(CASE WHEN read = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS unread,
685
+ SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
686
+ SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
687
+ SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
688
+ SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
689
+ SUM(CASE WHEN attachments IS NOT NULL AND attachments NOT IN ('', '[]') THEN 1 ELSE 0 END) AS attach,
690
+ MIN(received_at) AS first_at, MAX(received_at) AS latest_at
691
+ FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
692
+ const total = Number(agg[0]?.n ?? 0)
693
+ if (total === 0) {
694
+ await sql`DELETE FROM mail_threads WHERE owner = ${owner} AND thread_id = ${threadId}`
695
+ return
696
+ }
697
+ const latest = await sql`
698
+ SELECT id, subject, COALESCE(snippet, substr(COALESCE(body_text, ''), 1, 320)) AS snippet
699
+ FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
700
+ ORDER BY received_at DESC, id DESC LIMIT 1`
701
+ const members = await sql`
702
+ SELECT from_addr, labels FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
703
+ ORDER BY received_at ASC`
704
+ const senders: string[] = []
705
+ const labels = new Set<string>()
706
+ for (const member of members) {
707
+ const from = String(member.from_addr ?? '')
708
+ if (from && !senders.includes(from)) senders.push(from)
709
+ for (const label of parseJson<string[]>(member.labels, [])) labels.add(label)
710
+ }
711
+ const head = latest[0]
712
+ await sql`
713
+ INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
714
+ unread_count, starred_count, inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels)
715
+ VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
716
+ ${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
717
+ ${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
718
+ ${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].attach ?? 0)},
719
+ ${JSON.stringify(senders)}, ${String(head?.snippet ?? '')}, ${JSON.stringify([...labels])})
720
+ ON CONFLICT (owner, thread_id) DO UPDATE SET
721
+ subject_key = excluded.subject_key, subject = excluded.subject, first_at = excluded.first_at,
722
+ latest_at = excluded.latest_at, latest_id = excluded.latest_id, count = excluded.count,
723
+ unread_count = excluded.unread_count, starred_count = excluded.starred_count,
724
+ inbox_count = excluded.inbox_count, archived_count = excluded.archived_count,
725
+ trashed_count = excluded.trashed_count, attach_count = excluded.attach_count,
726
+ senders = excluded.senders, snippet = excluded.snippet, labels = excluded.labels`
727
+ }
728
+
729
+ /** Thread the message and refresh its summary; never lets a threading fault fail a write. */
730
+ // Live threading stays off until the thread index exists and history is backfilled; on
731
+ // before that, every inbound message would walk its whole mailbox to summarise one row.
732
+ export const threadsLive = () => process.env.THREADS_LIVE === '1'
733
+
734
+ async function threadMessage(row: { id: string; owner: string | null; subject: string; receivedAt: string }): Promise<void> {
735
+ if (!threadsLive() || !row.owner) return
736
+ try {
737
+ const threadId = await assignThread({ id: row.id, owner: row.owner, subject: row.subject, receivedAt: row.receivedAt })
738
+ const sql = db()
739
+ await sql`UPDATE mail_inbox SET thread_id = ${threadId} WHERE id = ${row.id}`
740
+ await refreshThread(row.owner, threadId)
741
+ } catch (err) {
742
+ console.error('[threads] could not thread', row.id, err instanceof Error ? err.message : err)
743
+ }
744
+ }
745
+
746
+ /** After a flag or owner change: refresh the row's thread (and the one it left, if any). */
747
+ async function rethreadAfterChange(id: string, previousOwner?: string | null, previousThread?: string | null): Promise<void> {
748
+ if (!threadsLive()) return
749
+ try {
750
+ const sql = db()
751
+ const rows = await sql`SELECT owner, thread_id, subject, received_at FROM mail_inbox WHERE id = ${id}`
752
+ const row = rows[0]
753
+ if (previousOwner && previousThread) await refreshThread(previousOwner, previousThread)
754
+ if (!row?.owner) return
755
+ if (!row.thread_id || (previousOwner && String(row.owner).toLowerCase() !== previousOwner.toLowerCase())) {
756
+ await threadMessage({ id, owner: String(row.owner), subject: String(row.subject ?? ''), receivedAt: String(row.received_at) })
757
+ return
758
+ }
759
+ await refreshThread(String(row.owner), String(row.thread_id))
760
+ } catch (err) {
761
+ console.error('[threads] could not refresh', id, err instanceof Error ? err.message : err)
762
+ }
763
+ }
764
+
765
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred'
766
+
767
+ /** The newest conversations in a folder: one row each, already summarised. */
768
+ export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
769
+
770
+ export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
771
+ await ensureMailSchema()
772
+ const owner = ownerRaw.toLowerCase()
773
+ const predicate =
774
+ folder === 'archive' ? 'archived_count > 0'
775
+ : folder === 'trash' ? 'trashed_count > 0'
776
+ : folder === 'starred' ? 'starred_count > 0'
777
+ : 'inbox_count > 0'
778
+ const cursor = decodeCursor(cursorRaw)
779
+ const rows = await tagged(db(), `
780
+ 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
782
+ FROM mail_threads WHERE owner = ? AND ${predicate}
783
+ ${cursor ? 'AND (latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''}
784
+ ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
785
+ cursor ? [owner, cursor.receivedAt, cursor.receivedAt, cursor.id, limit] : [owner, limit])
786
+ const last = rows[rows.length - 1]
787
+ const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
788
+ const mapped = rows.map(row => ({
789
+ threadId: String(row.thread_id),
790
+ subject: String(row.subject ?? ''),
791
+ firstAt: String(row.first_at),
792
+ latestAt: String(row.latest_at),
793
+ latestId: row.latest_id == null ? null : String(row.latest_id),
794
+ count: Number(row.count ?? 0),
795
+ unreadCount: Number(row.unread_count ?? 0),
796
+ starredCount: Number(row.starred_count ?? 0),
797
+ inboxCount: Number(row.inbox_count ?? 0),
798
+ archivedCount: Number(row.archived_count ?? 0),
799
+ trashedCount: Number(row.trashed_count ?? 0),
800
+ attachCount: Number(row.attach_count ?? 0),
801
+ senders: parseJson<string[]>(row.senders, []),
802
+ snippet: String(row.snippet ?? ''),
803
+ labels: parseJson<string[]>(row.labels, []),
804
+ }))
805
+ return { rows: mapped, nextCursor }
806
+ }
807
+
808
+ /**
809
+ * Backfill, phase one: give every message a thread id, oldest first so the thirty-day
810
+ * rule holds for history. Summaries are left for the second phase, so each thread is
811
+ * recomputed once rather than once per member.
812
+ */
813
+ export async function assignThreads(limit: number): Promise<{ assigned: number; remaining: number | null }> {
814
+ await ensureMailSchema()
815
+ const sql = db()
816
+ const rows = await sql`
817
+ SELECT id, owner, subject, received_at FROM mail_inbox
818
+ WHERE thread_id IS NULL ORDER BY received_at ASC LIMIT ${limit}`
819
+ if (rows.length === 0) return { assigned: 0, remaining: 0 }
820
+ for (const row of rows) {
821
+ const id = String(row.id)
822
+ const receivedAt = String(row.received_at)
823
+ if (!row.owner) {
824
+ await sql`UPDATE mail_inbox SET thread_id = ${threadIdFor('', receivedAt, id)} WHERE id = ${id}`
825
+ continue
826
+ }
827
+ const threadId = await assignThread({ id, owner: String(row.owner), subject: String(row.subject ?? ''), receivedAt })
828
+ await sql`UPDATE mail_inbox SET thread_id = ${threadId} WHERE id = ${id}`
829
+ // The thirty-day lookup reads mail_threads, so the thread has to exist before its next
830
+ // member arrives; a minimal row is enough until phase two fills it in.
831
+ const owner = String(row.owner).toLowerCase()
832
+ await sql`
833
+ INSERT INTO mail_threads (owner, thread_id, subject_key, first_at, latest_at)
834
+ VALUES (${owner}, ${threadId}, ${subjectKey(String(row.subject ?? ''))}, ${receivedAt}, ${receivedAt})
835
+ ON CONFLICT (owner, thread_id) DO UPDATE SET
836
+ first_at = MIN(mail_threads.first_at, excluded.first_at),
837
+ latest_at = MAX(mail_threads.latest_at, excluded.latest_at)`
838
+ }
839
+ return { assigned: rows.length, remaining: null }
840
+ }
841
+
842
+ /**
843
+ * Backfill, phase two: recompute each thread's summary once, walking (owner, thread_id)
844
+ * in order from a cursor so the index is read a single time overall.
845
+ */
846
+ export async function refreshThreadsFrom(
847
+ after: { owner: string; threadId: string } | null,
848
+ limit: number,
849
+ ): Promise<{ refreshed: number; cursor: { owner: string; threadId: string } | null }> {
850
+ await ensureMailSchema()
851
+ const sql = db()
852
+ const pairs = after
853
+ ? await sql`
854
+ SELECT DISTINCT lower(owner) AS owner, thread_id FROM mail_inbox
855
+ WHERE thread_id IS NOT NULL AND owner IS NOT NULL
856
+ AND (lower(owner) > ${after.owner} OR (lower(owner) = ${after.owner} AND thread_id > ${after.threadId}))
857
+ ORDER BY 1, 2 LIMIT ${limit}`
858
+ : await sql`
859
+ SELECT DISTINCT lower(owner) AS owner, thread_id FROM mail_inbox
860
+ WHERE thread_id IS NOT NULL AND owner IS NOT NULL
861
+ ORDER BY 1, 2 LIMIT ${limit}`
862
+ for (const pair of pairs) await refreshThread(String(pair.owner), String(pair.thread_id))
863
+ const last = pairs[pairs.length - 1]
864
+ return { refreshed: pairs.length, cursor: last ? { owner: String(last.owner), threadId: String(last.thread_id) } : null }
865
+ }
866
+
867
+ export async function appendInbound(
868
+ email: Omit<InboundEmail, 'starred' | 'archived' | 'trashed' | 'labels' | 'threadId'>,
869
+ ): Promise<void> {
870
+ await ensureMailSchema()
871
+ const sql = db()
872
+ await sql`
873
+ INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta)
874
+ VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)})
875
+ ON CONFLICT (id) DO NOTHING`
876
+ await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
877
+ await invalidateCounts(email.owner)
878
+ }
879
+
880
+ export async function getInboundSource(
881
+ id: string,
882
+ ): Promise<{ owner: string | null; attachments: Array<Record<string, unknown>> } | null> {
883
+ await ensureMailSchema()
884
+ const sql = db()
885
+ const rows = await sql`SELECT owner, attachments FROM mail_inbox WHERE id = ${id}`
886
+ if (!rows[0]) return null
887
+ const parsed = parseJson<unknown>(rows[0].attachments, [])
888
+ return {
889
+ owner: rows[0].owner == null ? null : String(rows[0].owner),
890
+ attachments: Array.isArray(parsed) ? (parsed as Array<Record<string, unknown>>) : [],
891
+ }
892
+ }
893
+
894
+ /**
895
+ * A message body lives in one of three places: the row itself, the primary row a shared
896
+ * copy points at, or the bucket. Imported archives keep HTML in the bucket so the database
897
+ * holds only text and metadata; the plain-text body always stays in the row for search.
898
+ */
899
+ /**
900
+ * Move message HTML out of the database and into the bucket, in batches.
901
+ *
902
+ * Runs server-side so the bodies travel Turso -> Vercel -> R2 inside the datacenter and
903
+ * never cross the mailbox owner's connection. A shared copy needs no upload at all: its
904
+ * body is byte-identical to the primary's, so it simply drops its duplicate and reads
905
+ * through. body_text stays in the row, which is what the search index is built from.
906
+ */
907
+ /**
908
+ * Fills the list columns from the message itself, entirely inside the database — the rows
909
+ * never cross the network. COALESCE in the list query means a row that has not been
910
+ * reached yet still reads correctly from the original columns.
911
+ */
912
+ const listSnippet = (text: string | null | undefined) => (text ?? '').slice(0, 320)
913
+
914
+ const headerString = (headers: Record<string, unknown> | undefined, key: string): string => {
915
+ if (!headers) return ''
916
+ const found = Object.keys(headers).find(name => name.toLowerCase() === key)
917
+ return found ? String(headers[found] ?? '') : ''
918
+ }
919
+
920
+ const threadMeta = (headers: Record<string, unknown> | undefined) =>
921
+ JSON.stringify({
922
+ 'message-id': headerString(headers, 'message-id'),
923
+ 'in-reply-to': headerString(headers, 'in-reply-to'),
924
+ references: headerString(headers, 'references'),
925
+ })
926
+
927
+ const attachMeta = (attachments: Array<Record<string, unknown>> | undefined) =>
928
+ JSON.stringify(
929
+ (attachments ?? []).map(entry => ({
930
+ filename: entry.filename,
931
+ contentType: entry.contentType,
932
+ size: entry.size,
933
+ // Without this the list forgets the file was a shared link, and the tile it draws
934
+ // offers neither a preview nor anywhere to go.
935
+ ...(entry.shareId ? { shareId: entry.shareId } : {}),
936
+ })),
937
+ )
938
+
939
+ export async function backfillListColumns(limit: number): Promise<{ filled: number; remaining: number | null }> {
940
+ await ensureMailSchema()
941
+ const sql = db()
942
+ // Newest first: those are the rows every mailbox opens on, so the first few thousand
943
+ // buy nearly all of the benefit long before the whole archive is done.
944
+ const rows = await sql`
945
+ SELECT id FROM mail_inbox WHERE snippet IS NULL
946
+ ORDER BY received_at DESC LIMIT ${limit}`
947
+ if (rows.length === 0) return { filled: 0, remaining: 0 }
948
+
949
+ const ids = rows.map(row => String(row.id)).filter(id => /^[A-Za-z0-9._:+@-]{1,200}$/.test(id))
950
+ if (ids.length === 0) return { filled: 0, remaining: null }
951
+ const list = ids.map(id => `'${id}'`).join(',')
952
+
953
+ await dbBatch([
954
+ `UPDATE mail_inbox SET
955
+ snippet = substr(COALESCE(body_text, ''), 1, 320),
956
+ thread_meta = json_object(
957
+ 'message-id', COALESCE(json_extract(headers, '$."message-id"'), ''),
958
+ 'in-reply-to', COALESCE(json_extract(headers, '$."in-reply-to"'), ''),
959
+ 'references', COALESCE(json_extract(headers, '$."references"'), '')),
960
+ attach_meta = COALESCE(
961
+ (SELECT json_group_array(json_object('filename', json_extract(value, '$.filename'),
962
+ 'contentType', json_extract(value, '$.contentType'),
963
+ 'size', json_extract(value, '$.size')))
964
+ FROM json_each(CASE WHEN json_valid(attachments) THEN attachments ELSE '[]' END)),
965
+ '[]')
966
+ WHERE id IN (${list})`,
967
+ ])
968
+ return { filled: ids.length, remaining: null }
969
+ }
970
+
971
+ export async function migrateBodiesToBucket(limit: number, countRemaining = false): Promise<{
972
+ moved: number
973
+ deduped: number
974
+ failed: number
975
+ bytes: number
976
+ remaining: number | null
977
+ }> {
978
+ await ensureMailSchema()
979
+ const sql = db()
980
+ const rows = await sql`
981
+ SELECT id, html, headers FROM mail_inbox
982
+ WHERE html IS NOT NULL AND html != '' LIMIT ${limit}`
983
+
984
+ const result: { moved: number; deduped: number; failed: number; bytes: number; remaining: number | null } = {
985
+ moved: 0, deduped: 0, failed: 0, bytes: 0, remaining: null,
986
+ }
987
+ if (rows.length === 0) return result
988
+
989
+ const { presign } = await import('./r2')
990
+ const statements: string[] = []
991
+
992
+ // Ids are generated by us, but this is string-interpolated SQL: anything unexpected
993
+ // is left alone rather than concatenated in.
994
+ const safeId = (value: string) => /^[A-Za-z0-9._:+@-]{1,200}$/.test(value)
995
+
996
+ const uploads = rows.map(async row => {
997
+ const id = String(row.id)
998
+ const html = String(row.html ?? '')
999
+ if (!safeId(id)) return
1000
+ const headers = parseJson<Record<string, unknown>>(row.headers, {})
1001
+
1002
+ if (typeof headers['shared-copy-of'] === 'string' && headers['shared-copy-of']) {
1003
+ statements.push(`UPDATE mail_inbox SET html = NULL WHERE id = '${id}'`)
1004
+ result.deduped += 1
1005
+ result.bytes += html.length
1006
+ return
1007
+ }
1008
+
1009
+ const key = `bodies/${id}.html`
1010
+ try {
1011
+ const response = await fetch(presign(key, 'PUT', 300), {
1012
+ method: 'PUT',
1013
+ headers: { 'content-type': 'text/html; charset=utf-8' },
1014
+ body: html,
1015
+ })
1016
+ if (!response.ok) {
1017
+ result.failed += 1
1018
+ return
1019
+ }
1020
+ } catch {
1021
+ result.failed += 1
1022
+ return
1023
+ }
1024
+
1025
+ // Cleared only once the bucket has the bytes, and json_set keeps the other headers.
1026
+ statements.push(
1027
+ `UPDATE mail_inbox SET html = NULL, headers = json_set(COALESCE(headers, '{}'), '$."html-key"', '${key}') WHERE id = '${id}'`,
1028
+ )
1029
+ result.moved += 1
1030
+ result.bytes += html.length
1031
+ })
1032
+
1033
+ await Promise.all(uploads)
1034
+ if (statements.length) await dbBatch(statements)
1035
+
1036
+ // Counting what is left scans every remaining body and costs far more than moving the
1037
+ // batch does, so it is asked for explicitly rather than charged to every call.
1038
+ if (countRemaining) {
1039
+ const left = await sql`SELECT COUNT(*) AS n FROM mail_inbox WHERE html IS NOT NULL AND html != ''`
1040
+ result.remaining = Number(left[0]?.n ?? 0)
1041
+ }
1042
+ return result
1043
+ }
1044
+
1045
+ /** Full body for one message: the list only carries a snippet, so the reader fetches this. */
1046
+ async function htmlFromBucket(key: unknown): Promise<string | null> {
1047
+ if (typeof key !== 'string' || !key) return null
1048
+ const { presign } = await import('./r2')
1049
+ const response = await fetch(presign(key, 'GET', 300))
1050
+ if (!response.ok) return null
1051
+ return response.text()
1052
+ }
1053
+
1054
+ /**
1055
+ * Opening a message used to cost three sequential round trips — one for the text, another
1056
+ * for the same row's html, a third to follow a shared copy to its primary — before the
1057
+ * bucket was even touched. The join resolves the copy in the one query, which matters
1058
+ * because most imported mail is a shared copy and the database is a continent away.
1059
+ */
1060
+ export async function resolveInboundBody(id: string): Promise<{ html: string | null; text: string | null }> {
1061
+ await ensureMailSchema()
1062
+ const sql = db()
1063
+ const rows = await sql`
1064
+ SELECT m.body_text AS body_text, m.html AS html, m.headers AS headers,
1065
+ p.html AS primary_html, p.headers AS primary_headers
1066
+ FROM mail_inbox m
1067
+ LEFT JOIN mail_inbox p ON p.id = json_extract(m.headers, '$."shared-copy-of"')
1068
+ WHERE m.id = ${id}`
1069
+ const row = rows[0]
1070
+ if (!row) return { html: null, text: null }
1071
+
1072
+ const text = row.body_text == null ? null : stripCidPlaceholders(String(row.body_text))
1073
+ const own = typeof row.html === 'string' && row.html ? row.html : null
1074
+ const shared = typeof row.primary_html === 'string' && row.primary_html ? row.primary_html : null
1075
+ if (own || shared) return { html: own ?? shared, text }
1076
+
1077
+ const headers = parseJson<Record<string, unknown>>(row.headers, {})
1078
+ const primaryHeaders = parseJson<Record<string, unknown>>(row.primary_headers, {})
1079
+ const html = (await htmlFromBucket(headers['html-key'])) ?? (await htmlFromBucket(primaryHeaders['html-key']))
1080
+ return { html, text }
1081
+ }
1082
+
1083
+ export async function resolveInboundHtml(id: string): Promise<string | null> {
1084
+ return (await resolveInboundBody(id)).html
1085
+ }
1086
+
1087
+ export async function getInboundAttachments(id: string): Promise<Array<Record<string, unknown>>> {
1088
+ return (await getInboundSource(id))?.attachments ?? []
1089
+ }
1090
+
1091
+ export type InboundAttachmentRow = {
1092
+ id: string
1093
+ subject: string
1094
+ from: string
1095
+ receivedAt: string
1096
+ attachments: Array<Record<string, unknown>>
1097
+ }
1098
+
1099
+ /** Every message that holds a stored attachment copy, newest first. */
1100
+ export async function listInboundWithAttachments(owner: string | null): Promise<InboundAttachmentRow[]> {
1101
+ await ensureMailSchema()
1102
+ const sql = db()
1103
+ const rows = owner
1104
+ ? await sql`
1105
+ SELECT id, subject, from_addr, received_at, attachments FROM mail_inbox
1106
+ WHERE lower(owner) = ${owner.toLowerCase()} AND attachments LIKE '%"url"%' AND trashed = 0
1107
+ ORDER BY received_at DESC LIMIT 500`
1108
+ : await sql`
1109
+ SELECT id, subject, from_addr, received_at, attachments FROM mail_inbox
1110
+ WHERE attachments LIKE '%"url"%' AND trashed = 0
1111
+ ORDER BY received_at DESC LIMIT 500`
1112
+ return rows.map(row => {
1113
+ const parsed = parseJson<unknown>(row.attachments, [])
1114
+ return {
1115
+ id: String(row.id),
1116
+ subject: String(row.subject ?? ''),
1117
+ from: String(row.from_addr ?? ''),
1118
+ receivedAt: String(row.received_at ?? ''),
1119
+ attachments: Array.isArray(parsed) ? (parsed as Array<Record<string, unknown>>) : [],
1120
+ }
1121
+ })
1122
+ }
1123
+
1124
+ export async function setInboxOwner(id: string, owner: string | null): Promise<void> {
1125
+ const sql = db()
1126
+ const before = await sql`SELECT owner, thread_id FROM mail_inbox WHERE id = ${id}`
1127
+ await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL WHERE id = ${id}`
1128
+ await rethreadAfterChange(id, before[0]?.owner == null ? null : String(before[0].owner), before[0]?.thread_id == null ? null : String(before[0].thread_id))
1129
+ }
1130
+
1131
+ export async function markInboundRead(id: string): Promise<void> {
1132
+ const sql = db()
1133
+ await sql`UPDATE mail_inbox SET read = 1 WHERE id = ${id}`
1134
+ await rethreadAfterChange(id)
1135
+ }
1136
+
1137
+ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<void> {
1138
+ const sql = db()
1139
+ if (flags.read !== undefined) await sql`UPDATE mail_inbox SET read = ${flags.read} WHERE id = ${id}`
1140
+ if (flags.starred !== undefined) await sql`UPDATE mail_inbox SET starred = ${flags.starred} WHERE id = ${id}`
1141
+ if (flags.archived !== undefined) await sql`UPDATE mail_inbox SET archived = ${flags.archived} WHERE id = ${id}`
1142
+ if (flags.trashed !== undefined) await sql`UPDATE mail_inbox SET trashed = ${flags.trashed} WHERE id = ${id}`
1143
+ const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1144
+ await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1145
+ await rethreadAfterChange(id)
1146
+ }
1147
+
1148
+ export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
1149
+ const sql = db()
1150
+ await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
1151
+ }
1152
+
1153
+ // ── Sent-mail flags (star / archive / trash on Resend-sent emails) ──
1154
+ export type SentFlags = { starred?: boolean; archived?: boolean; trashed?: boolean }
1155
+
1156
+ export async function readSentFlags(): Promise<Record<string, Required<SentFlags>>> {
1157
+ await ensureMailSchema()
1158
+ const sql = db()
1159
+ const rows = await sql`SELECT email_id, starred, archived, trashed FROM mail_sent_flags`
1160
+ const out: Record<string, Required<SentFlags>> = {}
1161
+ for (const row of rows) {
1162
+ out[String(row.email_id)] = {
1163
+ starred: Boolean(row.starred),
1164
+ archived: Boolean(row.archived),
1165
+ trashed: Boolean(row.trashed),
1166
+ }
1167
+ }
1168
+ return out
1169
+ }
1170
+
1171
+ export async function setSentFlags(id: string, flags: SentFlags): Promise<void> {
1172
+ await ensureMailSchema()
1173
+ const sql = db()
1174
+ await sql`
1175
+ INSERT INTO mail_sent_flags (email_id, starred, archived, trashed, updated_at)
1176
+ VALUES (${id}, ${flags.starred ?? false}, ${flags.archived ?? false}, ${flags.trashed ?? false}, ${nowIso()})
1177
+ ON CONFLICT (email_id) DO UPDATE SET
1178
+ starred = COALESCE(${flags.starred ?? null}, mail_sent_flags.starred),
1179
+ archived = COALESCE(${flags.archived ?? null}, mail_sent_flags.archived),
1180
+ trashed = COALESCE(${flags.trashed ?? null}, mail_sent_flags.trashed),
1181
+ updated_at = ${nowIso()}`
1182
+ }
1183
+
1184
+ // ── Open-tracking pixels ───────────────────────────────────────
1185
+ export type PixelOpen = { opened: boolean; openCount: number; openedAt: string | null }
1186
+
1187
+ export async function recordPixel(pixelId: string, emailId: string, recipient: string, subject: string): Promise<void> {
1188
+ await ensureMailSchema()
1189
+ const sql = db()
1190
+ await sql`
1191
+ INSERT INTO mail_pixels (pixel_id, email_id, recipient, subject, created_at)
1192
+ VALUES (${pixelId}, ${emailId}, ${recipient}, ${subject}, ${nowIso()})
1193
+ ON CONFLICT (pixel_id) DO NOTHING`
1194
+ }
1195
+
1196
+ export async function markPixelOpened(pixelId: string): Promise<void> {
1197
+ const sql = db()
1198
+ await sql`
1199
+ UPDATE mail_pixels
1200
+ SET open_count = open_count + 1, opened_at = COALESCE(opened_at, ${nowIso()})
1201
+ WHERE pixel_id = ${pixelId}`
1202
+ }
1203
+
1204
+ export async function readPixelOpens(): Promise<Record<string, PixelOpen>> {
1205
+ await ensureMailSchema()
1206
+ const sql = db()
1207
+ const rows = await sql`
1208
+ SELECT email_id, SUM(open_count) AS opens, MIN(opened_at) AS first_open
1209
+ FROM mail_pixels WHERE email_id IS NOT NULL GROUP BY email_id`
1210
+ const out: Record<string, PixelOpen> = {}
1211
+ for (const row of rows) {
1212
+ const opens = Number(row.opens ?? 0)
1213
+ out[String(row.email_id)] = {
1214
+ opened: opens > 0,
1215
+ openCount: opens,
1216
+ openedAt: isoOrNull(row.first_open),
1217
+ }
1218
+ }
1219
+ return out
1220
+ }
1221
+
1222
+ // ── Contacts (recipient autocomplete) ──────────────────────────
1223
+ export async function recordContact(rawEmail: string, name: string | null): Promise<void> {
1224
+ // Callers pass raw header values, which may be "Display Name <addr@host>" — store only
1225
+ // the address, or the autocomplete fills up with unusable entries.
1226
+ const angle = rawEmail.match(/<([^>]+)>/)
1227
+ const email = (angle ? angle[1] : rawEmail).trim().toLowerCase()
1228
+ const display = name ?? (angle ? rawEmail.slice(0, rawEmail.indexOf('<')).replace(/["']/g, '').trim() || null : null)
1229
+ if (!email || !email.includes('@') || /\s/.test(email)) return
1230
+ await ensureMailSchema()
1231
+ const sql = db()
1232
+ await sql`
1233
+ INSERT INTO mail_contacts (email, name, seen_count, last_seen)
1234
+ VALUES (${email}, ${display}, 1, ${nowIso()})
1235
+ ON CONFLICT (email) DO UPDATE SET
1236
+ seen_count = mail_contacts.seen_count + 1,
1237
+ last_seen = ${nowIso()},
1238
+ name = COALESCE(excluded.name, mail_contacts.name)`
1239
+ }
1240
+
1241
+ export async function searchContacts(query: string): Promise<Contact[]> {
1242
+ await ensureMailSchema()
1243
+ const sql = db()
1244
+ const term = `%${query.trim().toLowerCase()}%`
1245
+ const rows = query.trim()
1246
+ ? await sql`SELECT email, name FROM mail_contacts WHERE lower(email) LIKE ${term} OR lower(coalesce(name,'')) LIKE ${term} ORDER BY seen_count DESC, last_seen DESC LIMIT 8`
1247
+ : await sql`SELECT email, name FROM mail_contacts ORDER BY seen_count DESC, last_seen DESC LIMIT 8`
1248
+ return rows.map(row => ({ email: String(row.email), name: (row.name as string) ?? null }))
1249
+ }
1250
+
1251
+ // ── Settings ───────────────────────────────────────────────────
1252
+ export async function getSettings(owner: string): Promise<Record<string, unknown>> {
1253
+ await ensureMailSchema()
1254
+ const sql = db()
1255
+ const rows = await sql`SELECT data FROM mail_settings WHERE owner = ${owner.toLowerCase()}`
1256
+ return parseJson<Record<string, unknown>>(rows[0]?.data, {})
1257
+ }
1258
+
1259
+ export async function setSettings(owner: string, data: Record<string, unknown>): Promise<void> {
1260
+ await ensureMailSchema()
1261
+ const sql = db()
1262
+ await sql`
1263
+ INSERT INTO mail_settings (owner, data) VALUES (${owner.toLowerCase()}, ${JSON.stringify(data)})
1264
+ ON CONFLICT (owner) DO UPDATE SET data = excluded.data`
1265
+ }
1266
+
1267
+ // ── Web Push ───────────────────────────────────────────────────
1268
+ export type PushSubscriptionRow = { endpoint: string; keys: { p256dh: string; auth: string } }
1269
+
1270
+ export async function savePushSubscription(owner: string, subscription: PushSubscriptionRow): Promise<void> {
1271
+ await ensureMailSchema()
1272
+ const sql = db()
1273
+ await sql`
1274
+ INSERT INTO mail_push_subscriptions (endpoint, owner, subscription, created_at)
1275
+ VALUES (${subscription.endpoint}, ${owner.toLowerCase()}, ${JSON.stringify(subscription)}, ${nowIso()})
1276
+ ON CONFLICT (endpoint) DO UPDATE SET owner = excluded.owner, subscription = excluded.subscription`
1277
+ }
1278
+
1279
+ export async function deletePushSubscription(endpoint: string): Promise<void> {
1280
+ await ensureMailSchema()
1281
+ await db()`DELETE FROM mail_push_subscriptions WHERE endpoint = ${endpoint}`
1282
+ }
1283
+
1284
+ export async function listPushSubscriptions(owner: string): Promise<PushSubscriptionRow[]> {
1285
+ await ensureMailSchema()
1286
+ const rows = await db()`SELECT subscription FROM mail_push_subscriptions WHERE owner = ${owner.toLowerCase()}`
1287
+ return rows.map(row => parseJson<PushSubscriptionRow | null>(row.subscription, null)).filter((row): row is PushSubscriptionRow => Boolean(row?.endpoint))
1288
+ }
1289
+
1290
+ // ── Events ─────────────────────────────────────────────────────
1291
+ export async function readEvents(): Promise<MailEvent[]> {
1292
+ await ensureMailSchema()
1293
+ const sql = db()
1294
+ const rows = await sql`SELECT email_id, type, at, meta FROM mail_events ORDER BY at DESC LIMIT ${MAX_EVENTS}`
1295
+ return rows.map(row => {
1296
+ const meta = parseJson<Record<string, string> | null>(row.meta, null)
1297
+ return {
1298
+ emailId: String(row.email_id ?? ''),
1299
+ type: String(row.type ?? ''),
1300
+ at: isoOrNull(row.at) ?? new Date(0).toISOString(),
1301
+ meta: meta ?? undefined,
1302
+ }
1303
+ })
1304
+ }
1305
+
1306
+ export async function appendEvent(event: MailEvent): Promise<void> {
1307
+ await ensureMailSchema()
1308
+ const sql = db()
1309
+ await sql`
1310
+ INSERT INTO mail_events (email_id, type, at, meta)
1311
+ VALUES (${event.emailId}, ${event.type}, ${event.at}, ${event.meta ? JSON.stringify(event.meta) : null})`
1312
+ }
1313
+
1314
+ // ── Accounts, roles & password reset ───────────────────────────
1315
+ export type { MailRole }
1316
+ /**
1317
+ * The mailboxes this deployment serves. `email` is the sign-in identity and `address` is
1318
+ * the mailbox it owns; for most tenants they are the same, so a person signs in as the
1319
+ * address they are known by. info@ owns the shared inbox, which makes it the admin.
1320
+ */
1321
+ /**
1322
+ * Delivery aliases. The aliased seat still exists and can sign in; only mail addressed to
1323
+ * it is stored under the target's box, because that is who reads it now.
1324
+ */
1325
+ export { ADDRESS_ALIASES, MAIL_SEATS }
1326
+
1327
+ export type MailAccount = {
1328
+ email: string
1329
+ name: string | null
1330
+ address: string | null
1331
+ role: MailRole
1332
+ status: string
1333
+ hasPassword: boolean
1334
+ createdAt: string | null
1335
+ invitedBy: string | null
1336
+ }
1337
+
1338
+ function mapAccount(row: Record<string, unknown>): MailAccount {
1339
+ return {
1340
+ email: String(row.email),
1341
+ name: (row.name as string) ?? null,
1342
+ address: (row.address as string) ?? null,
1343
+ role: row.role === 'admin' ? 'admin' : 'member',
1344
+ status: (row.status as string) ?? 'active',
1345
+ hasPassword: Boolean(row.has_password),
1346
+ createdAt: isoOrNull(row.created_at),
1347
+ invitedBy: (row.invited_by as string) ?? null,
1348
+ }
1349
+ }
1350
+
1351
+ export async function listAccounts(): Promise<MailAccount[]> {
1352
+ await ensureMailSchema()
1353
+ const sql = db()
1354
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
1355
+ return rows.map(mapAccount)
1356
+ }
1357
+
1358
+ export async function getAccount(email: string): Promise<MailAccount | null> {
1359
+ await ensureMailSchema()
1360
+ const sql = db()
1361
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1362
+ return rows[0] ? mapAccount(rows[0]) : null
1363
+ }
1364
+
1365
+ export async function getAccountByAddress(address: string): Promise<MailAccount | null> {
1366
+ await ensureMailSchema()
1367
+ const sql = db()
1368
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
1369
+ return rows[0] ? mapAccount(rows[0]) : null
1370
+ }
1371
+
1372
+ export async function createAccount(input: {
1373
+ email: string
1374
+ address: string
1375
+ role?: MailRole
1376
+ name?: string | null
1377
+ status?: string
1378
+ invitedBy?: string | null
1379
+ }): Promise<void> {
1380
+ await ensureMailSchema()
1381
+ const sql = db()
1382
+ await sql`
1383
+ INSERT INTO mail_accounts (email, name, address, role, status, invited_by, created_at)
1384
+ VALUES (${input.email.trim().toLowerCase()}, ${input.name ?? null}, ${input.address.trim().toLowerCase()}, ${input.role ?? 'member'}, ${input.status ?? 'pending'}, ${input.invitedBy ?? null}, ${nowIso()})
1385
+ ON CONFLICT (email) DO UPDATE SET
1386
+ name = excluded.name, address = excluded.address, role = excluded.role, invited_by = excluded.invited_by`
1387
+ }
1388
+
1389
+ export async function updateAccount(email: string, patch: { name?: string | null; role?: MailRole; address?: string; status?: string }): Promise<void> {
1390
+ await ensureMailSchema()
1391
+ const sql = db()
1392
+ if (patch.name !== undefined) await sql`UPDATE mail_accounts SET name = ${patch.name} WHERE email = ${email.toLowerCase()}`
1393
+ if (patch.role !== undefined) await sql`UPDATE mail_accounts SET role = ${patch.role} WHERE email = ${email.toLowerCase()}`
1394
+ if (patch.address !== undefined) await sql`UPDATE mail_accounts SET address = ${patch.address.trim().toLowerCase()} WHERE email = ${email.toLowerCase()}`
1395
+ if (patch.status !== undefined) await sql`UPDATE mail_accounts SET status = ${patch.status} WHERE email = ${email.toLowerCase()}`
1396
+ }
1397
+
1398
+ export async function deleteAccount(email: string): Promise<void> {
1399
+ await ensureMailSchema()
1400
+ const sql = db()
1401
+ await sql`DELETE FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1402
+ }
1403
+
1404
+ // ── Sent-mail archive (our own copy, independent of any provider) ─
1405
+ export type SentMessage = {
1406
+ id: string
1407
+ from: string
1408
+ to: string[]
1409
+ cc: string[]
1410
+ bcc: string[]
1411
+ replyTo: string[]
1412
+ subject: string
1413
+ html: string | null
1414
+ text: string | null
1415
+ createdAt: string
1416
+ lastEvent?: string | null
1417
+ /** The bucket keys that went out with it, so a forward has something of ours to copy. */
1418
+ attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
1419
+ }
1420
+
1421
+ export async function recordSentMessage(message: SentMessage): Promise<void> {
1422
+ if (!message.id) return
1423
+ await ensureMailSchema()
1424
+ const sql = db()
1425
+ await sql`
1426
+ INSERT INTO mail_sent (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, created_at, last_event, provider, archived_at, attachments)
1427
+ VALUES (${message.id}, ${message.from}, ${JSON.stringify(message.to)}, ${JSON.stringify(message.cc)}, ${JSON.stringify(message.bcc)}, ${JSON.stringify(message.replyTo)}, ${message.subject}, ${message.html}, ${message.text}, ${message.createdAt}, ${message.lastEvent ?? null}, ${process.env.MAIL_PROVIDER ?? 'resend'}, ${nowIso()}, ${JSON.stringify(message.attachments ?? [])})
1428
+ ON CONFLICT (id) DO NOTHING`
1429
+ }
1430
+
1431
+ export async function readSentArchive(): Promise<SentMessage[]> {
1432
+ await ensureMailSchema()
1433
+ const sql = db()
1434
+ // 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`
1439
+ return rows.map(row => ({
1440
+ id: String(row.id),
1441
+ from: (row.from_addr as string) ?? '',
1442
+ to: parseArray(row.to_addrs),
1443
+ cc: parseArray(row.cc),
1444
+ bcc: parseArray(row.bcc),
1445
+ replyTo: parseArray(row.reply_to),
1446
+ subject: (row.subject as string) ?? '',
1447
+ html: null,
1448
+ text: null,
1449
+ createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1450
+ lastEvent: (row.last_event as string) ?? null,
1451
+ }))
1452
+ }
1453
+
1454
+ // ── Sent-mail attribution (owner + automated flag + thread link) ─
1455
+ export type SentMeta = { owner: string | null; isAuto: boolean; inReplyTo: string | null }
1456
+
1457
+ export async function recordSentMeta(
1458
+ emailId: string,
1459
+ owner: string | null,
1460
+ isAuto: boolean,
1461
+ inReplyTo?: string | null,
1462
+ ): Promise<void> {
1463
+ if (!emailId) return
1464
+ await ensureMailSchema()
1465
+ const sql = db()
1466
+ const normalizedInReplyTo = inReplyTo ? inReplyTo.replace(/[<>]/g, '').trim() || null : null
1467
+ await sql`
1468
+ INSERT INTO mail_sent_meta (email_id, owner, is_auto, in_reply_to, created_at)
1469
+ VALUES (${emailId}, ${owner ? owner.toLowerCase() : null}, ${isAuto}, ${normalizedInReplyTo}, ${nowIso()})
1470
+ ON CONFLICT (email_id) DO UPDATE SET
1471
+ owner = COALESCE(excluded.owner, mail_sent_meta.owner),
1472
+ is_auto = excluded.is_auto,
1473
+ in_reply_to = COALESCE(excluded.in_reply_to, mail_sent_meta.in_reply_to)`
1474
+ }
1475
+
1476
+ /** Explicitly (re)assign a sent email to a mailbox owner, preserving its is_auto flag. */
1477
+ export async function setSentMetaOwner(emailId: string, owner: string): Promise<void> {
1478
+ if (!emailId) return
1479
+ await ensureMailSchema()
1480
+ const sql = db()
1481
+ await sql`
1482
+ INSERT INTO mail_sent_meta (email_id, owner, created_at) VALUES (${emailId}, ${owner.trim().toLowerCase()}, ${nowIso()})
1483
+ ON CONFLICT (email_id) DO UPDATE SET owner = excluded.owner`
1484
+ }
1485
+
1486
+ export async function readSentMessage(id: string): Promise<SentMessage | null> {
1487
+ await ensureMailSchema()
1488
+ const sql = db()
1489
+ const rows = await sql`
1490
+ SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, created_at, last_event
1491
+ FROM mail_sent WHERE id = ${id}`
1492
+ const row = rows[0]
1493
+ if (!row) return null
1494
+ return {
1495
+ id: String(row.id),
1496
+ from: (row.from_addr as string) ?? '',
1497
+ to: parseArray(row.to_addrs),
1498
+ cc: parseArray(row.cc),
1499
+ bcc: parseArray(row.bcc),
1500
+ replyTo: parseArray(row.reply_to),
1501
+ subject: (row.subject as string) ?? '',
1502
+ html: (row.html as string) ?? null,
1503
+ text: (row.body_text as string) ?? null,
1504
+ createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1505
+ lastEvent: (row.last_event as string) ?? null,
1506
+ }
1507
+ }
1508
+
1509
+ export async function readSentMeta(emailIds?: string[]): Promise<Record<string, SentMeta>> {
1510
+ await ensureMailSchema()
1511
+ const sql = db()
1512
+ // The caller only ever needs rows for the messages it is about to return. Unscoped,
1513
+ // this read the whole table — thirty-odd thousand rows — on every poll of every tab,
1514
+ // 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}`
1518
+ 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,
1524
+ }
1525
+ }
1526
+ return out
1527
+ }
1528
+
1529
+ export async function getAccountPasswordHash(email: string): Promise<string | undefined> {
1530
+ await ensureMailSchema()
1531
+ const sql = db()
1532
+ const rows = await sql`SELECT password_hash FROM mail_accounts WHERE email = ${email.toLowerCase()}`
1533
+ const hash = rows[0]?.password_hash
1534
+ return hash === null || hash === undefined ? undefined : String(hash)
1535
+ }
1536
+
1537
+ export async function setAccountPassword(email: string, passwordHash: string): Promise<void> {
1538
+ await ensureMailSchema()
1539
+ const sql = db()
1540
+ await sql`
1541
+ UPDATE mail_accounts SET password_hash = ${passwordHash}, password_is_default = 0
1542
+ WHERE email = ${email.toLowerCase()}`
1543
+ }
1544
+
1545
+ /** True while the account is still on the address-derived password it was seeded with. */
1546
+ export async function usingDefaultPassword(email: string): Promise<boolean> {
1547
+ await ensureMailSchema()
1548
+ const sql = db()
1549
+ const rows = await sql`SELECT password_is_default FROM mail_accounts WHERE email = ${email.toLowerCase()}`
1550
+ return Number(rows[0]?.password_is_default ?? 0) === 1
1551
+ }
1552
+
1553
+
1554
+ /** Every outstanding reset link for an address, dropped. Used after a password changes. */
1555
+ export async function clearResetTokens(email: string): Promise<void> {
1556
+ await ensureMailSchema()
1557
+ const sql = db()
1558
+ await sql`DELETE FROM mail_reset_tokens WHERE email = ${email.trim().toLowerCase()}`
1559
+ }
1560
+
1561
+ export type ShareRecord = {
1562
+ id: string
1563
+ objectKey: string
1564
+ filename: string
1565
+ contentType: string | null
1566
+ size: number
1567
+ hasPassword: boolean
1568
+ expiresAt: string | null
1569
+ downloads: number
1570
+ maxDownloads: number | null
1571
+ revoked: boolean
1572
+ }
1573
+
1574
+ function mapShare(row: Record<string, unknown>): ShareRecord {
1575
+ return {
1576
+ id: String(row.id),
1577
+ objectKey: String(row.object_key),
1578
+ filename: String(row.filename),
1579
+ contentType: row.content_type == null ? null : String(row.content_type),
1580
+ size: Number(row.size ?? 0),
1581
+ hasPassword: Boolean(row.password_hash),
1582
+ expiresAt: row.expires_at == null ? null : String(row.expires_at),
1583
+ downloads: Number(row.downloads ?? 0),
1584
+ maxDownloads: row.max_downloads == null ? null : Number(row.max_downloads),
1585
+ revoked: Number(row.revoked ?? 0) === 1,
1586
+ }
1587
+ }
1588
+
1589
+ export async function createShare(input: {
1590
+ id: string
1591
+ objectKey: string
1592
+ filename: string
1593
+ contentType?: string | null
1594
+ size: number
1595
+ passwordHash?: string | null
1596
+ owner: string
1597
+ expiresAt?: string | null
1598
+ maxDownloads?: number | null
1599
+ }): Promise<void> {
1600
+ await ensureMailSchema()
1601
+ const sql = db()
1602
+ await sql`
1603
+ INSERT INTO mail_shares (id, object_key, filename, content_type, size, password_hash, owner, created_at, expires_at, max_downloads)
1604
+ VALUES (${input.id}, ${input.objectKey}, ${input.filename}, ${input.contentType ?? null}, ${input.size},
1605
+ ${input.passwordHash ?? null}, ${input.owner.toLowerCase()}, ${nowIso()}, ${input.expiresAt ?? null},
1606
+ ${input.maxDownloads ?? null})`
1607
+ }
1608
+
1609
+ export async function getShare(id: string): Promise<ShareRecord | null> {
1610
+ await ensureMailSchema()
1611
+ const sql = db()
1612
+ const rows = await sql`SELECT * FROM mail_shares WHERE id = ${id}`
1613
+ return rows[0] ? mapShare(rows[0]) : null
1614
+ }
1615
+
1616
+ /** The hash is never returned with the record, so it can only be read deliberately. */
1617
+ export async function getSharePasswordHash(id: string): Promise<string | null> {
1618
+ await ensureMailSchema()
1619
+ const sql = db()
1620
+ const rows = await sql`SELECT password_hash FROM mail_shares WHERE id = ${id}`
1621
+ const hash = rows[0]?.password_hash
1622
+ return hash == null ? null : String(hash)
1623
+ }
1624
+
1625
+ export async function recordShareDownload(id: string): Promise<void> {
1626
+ await ensureMailSchema()
1627
+ const sql = db()
1628
+ await sql`UPDATE mail_shares SET downloads = downloads + 1 WHERE id = ${id}`
1629
+ }
1630
+
1631
+ export async function listShares(owner: string): Promise<ShareRecord[]> {
1632
+ await ensureMailSchema()
1633
+ const sql = db()
1634
+ const rows = await sql`
1635
+ SELECT * FROM mail_shares WHERE owner = ${owner.toLowerCase()} ORDER BY created_at DESC LIMIT 100`
1636
+ return rows.map(mapShare)
1637
+ }
1638
+
1639
+ export async function setSharePassword(id: string, owner: string, passwordHash: string | null): Promise<boolean> {
1640
+ await ensureMailSchema()
1641
+ const sql = db()
1642
+ const rows = await sql`
1643
+ UPDATE mail_shares SET password_hash = ${passwordHash}
1644
+ WHERE id = ${id} AND owner = ${owner.toLowerCase()} RETURNING id`
1645
+ return rows.length > 0
1646
+ }
1647
+
1648
+ export async function revokeShare(id: string, owner: string): Promise<boolean> {
1649
+ await ensureMailSchema()
1650
+ const sql = db()
1651
+ const rows = await sql`
1652
+ UPDATE mail_shares SET revoked = 1 WHERE id = ${id} AND owner = ${owner.toLowerCase()} RETURNING id`
1653
+ return rows.length > 0
1654
+ }
1655
+
1656
+ export async function createResetToken(email: string, token: string, expires: number): Promise<void> {
1657
+ await ensureMailSchema()
1658
+ const sql = db()
1659
+ await sql`DELETE FROM mail_reset_tokens WHERE expires_at < ${nowIso()}`
1660
+ await sql`
1661
+ INSERT INTO mail_reset_tokens (token, email, expires_at)
1662
+ VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()})`
1663
+ }
1664
+
1665
+ /**
1666
+ * The address a live token belongs to, without consuming it, so the new password can be
1667
+ * checked against the account's own policy before the single-use token is spent.
1668
+ */
1669
+ export async function resetTokenEmail(token: string): Promise<string | null> {
1670
+ await ensureMailSchema()
1671
+ const sql = db()
1672
+ const rows = await sql`
1673
+ SELECT email FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()}`
1674
+ const email = rows[0]?.email
1675
+ return email ? String(email) : null
1676
+ }
1677
+
1678
+ /** Atomically consume a valid token and set the new password. Single-use, no race window. */
1679
+ export async function resetPasswordWithToken(token: string, passwordHash: string): Promise<string | null> {
1680
+ await ensureMailSchema()
1681
+ const sql = db()
1682
+ const consumed = await sql`
1683
+ DELETE FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()} RETURNING email`
1684
+ const email = consumed[0]?.email
1685
+ if (!email) return null
1686
+ await sql`
1687
+ INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
1688
+ ON CONFLICT (email) DO UPDATE SET password_hash = excluded.password_hash, status = 'active'`
1689
+ return String(email)
1690
+ }
1691
+
1692
+ // ── Per-account stash: drafts + saved templates ────────────────
1693
+ export async function readStash(owner: string, kind: string): Promise<StashItem[]> {
1694
+ await ensureMailSchema()
1695
+ const sql = db()
1696
+ const rows = await sql`
1697
+ SELECT id, data, updated_at FROM mail_stash
1698
+ WHERE owner = ${owner.toLowerCase()} AND kind = ${kind}
1699
+ ORDER BY updated_at DESC`
1700
+ return rows.map(row => ({
1701
+ id: String(row.id),
1702
+ data: parseJson<unknown>(row.data, null),
1703
+ updatedAt: isoOrNull(row.updated_at) ?? new Date(0).toISOString(),
1704
+ }))
1705
+ }
1706
+
1707
+ export async function upsertStash(owner: string, kind: string, id: string, data: unknown): Promise<void> {
1708
+ await ensureMailSchema()
1709
+ const sql = db()
1710
+ await sql`
1711
+ INSERT INTO mail_stash (owner, kind, id, data, updated_at)
1712
+ VALUES (${owner.toLowerCase()}, ${kind}, ${id}, ${JSON.stringify(data)}, ${nowIso()})
1713
+ ON CONFLICT (owner, kind, id) DO UPDATE SET data = excluded.data, updated_at = ${nowIso()}`
1714
+ }
1715
+
1716
+ export async function deleteStash(owner: string, kind: string, id: string): Promise<void> {
1717
+ await ensureMailSchema()
1718
+ const sql = db()
1719
+ await sql`DELETE FROM mail_stash WHERE owner = ${owner.toLowerCase()} AND kind = ${kind} AND id = ${id}`
1720
+ }
1721
+
1722
+ /**
1723
+ * Claims a webhook delivery id so the work behind it runs once. Storage is already
1724
+ * idempotent, but forwarding a copy on to the mailbox owner is not, so a provider
1725
+ * retrying a delivery it believes failed would otherwise send a second copy.
1726
+ *
1727
+ * The claim is a lease rather than a permanent mark. A serverless invocation can be
1728
+ * killed mid-flight — a platform timeout, a redeploy — and a claim that outlived its
1729
+ * process would refuse the retry and lose the message for good. An unfinished claim
1730
+ * older than the lease is therefore taken over rather than treated as a duplicate.
1731
+ */
1732
+ const CLAIM_LEASE_MS = 10 * 60 * 1000
1733
+
1734
+ export async function claimWebhookEvent(id: string): Promise<boolean> {
1735
+ if (!id) return true
1736
+ await ensureMailSchema()
1737
+
1738
+ const inserted = await sqlRaw(
1739
+ `INSERT INTO mail_webhook_events (id, handled_at, status) VALUES (?, ?, 'working')
1740
+ ON CONFLICT (id) DO NOTHING
1741
+ RETURNING id`,
1742
+ [id, nowIso()],
1743
+ )
1744
+ if (inserted.length > 0) return true
1745
+
1746
+ const existing = await sqlRaw('SELECT handled_at, status FROM mail_webhook_events WHERE id = ?', [id])
1747
+ const row = existing[0]
1748
+ if (!row) return true
1749
+ if (String(row.status) === 'done') return false
1750
+
1751
+ const startedAt = new Date(String(row.handled_at)).getTime()
1752
+ if (Number.isFinite(startedAt) && Date.now() - startedAt < CLAIM_LEASE_MS) return false
1753
+
1754
+ // The attempt holding this never finished and its lease has run out. Take it over.
1755
+ await sqlRaw('UPDATE mail_webhook_events SET handled_at = ? WHERE id = ?', [nowIso(), id])
1756
+ return true
1757
+ }
1758
+
1759
+ /** Marks the delivery finished, so later redeliveries of it are refused for good. */
1760
+ export async function completeWebhookEvent(id: string): Promise<void> {
1761
+ if (!id) return
1762
+ await sqlRaw("UPDATE mail_webhook_events SET status = 'done', handled_at = ? WHERE id = ?", [nowIso(), id])
1763
+ .catch(() => {})
1764
+ }
1765
+
1766
+ /** Hands the id back after a failed delivery, so the provider's retry is not treated as a duplicate. */
1767
+ export async function releaseWebhookEvent(id: string): Promise<void> {
1768
+ if (!id) return
1769
+ await sqlRaw('DELETE FROM mail_webhook_events WHERE id = ?', [id]).catch(() => {})
1770
+ }
1771
+
1772
+ /** Keeps the dedupe table bounded; retries never span anything close to this. */
1773
+ export async function pruneWebhookEvents(days = 30): Promise<void> {
1774
+ const cutoff = new Date(Date.now() - days * 86400_000).toISOString()
1775
+ await sqlRaw('DELETE FROM mail_webhook_events WHERE handled_at < ?', [cutoff]).catch(() => {})
1776
+ }
1777
+
1778
+ /** Replaces the stored attachment metadata, used when bytes are copied into the bucket after the fact. */
1779
+ export async function setInboundAttachments(id: string, attachments: unknown[]): Promise<void> {
1780
+ await ensureMailSchema()
1781
+ const sql = db()
1782
+ const json = JSON.stringify(attachments)
1783
+ await sql`UPDATE mail_inbox SET attachments = ${json}, attach_meta = ${json} WHERE id = ${id}`
1784
+ }
1785
+
1786
+ /**
1787
+ * Copies attachment bytes the provider is still holding into our own bucket.
1788
+ *
1789
+ * Only messages the provider actually delivered can be recovered this way. Anything
1790
+ * imported from the mail archive carries an `mbox-` id and was never in their hands,
1791
+ * so those are skipped rather than counted as failures.
1792
+ *
1793
+ * Runs on the deployment rather than a laptop on purpose: the provider, the bucket and
1794
+ * this code are all in the same region, so the bytes never leave it.
1795
+ */
1796
+ export async function backfillAttachments(limit: number, countRemaining = false): Promise<{
1797
+ scanned: number
1798
+ copied: number
1799
+ files: number
1800
+ bytes: number
1801
+ failed: number
1802
+ remaining: number | null
1803
+ }> {
1804
+ await ensureMailSchema()
1805
+ const result = { scanned: 0, copied: 0, files: 0, bytes: 0, failed: 0, remaining: null as number | null }
1806
+
1807
+ const pending = `
1808
+ FROM mail_inbox
1809
+ WHERE attachments IS NOT NULL AND attachments NOT IN ('', '[]')
1810
+ AND attachments NOT LIKE '%"unavailable"%'
1811
+ AND id NOT LIKE 'mbox-%'
1812
+ AND EXISTS (SELECT 1 FROM json_each(attachments) WHERE json_extract(value, '$.key') IS NULL)`
1813
+
1814
+ if (countRemaining) {
1815
+ const counted = await sqlRaw(`SELECT COUNT(*) AS n ${pending}`)
1816
+ result.remaining = Number(counted[0]?.n ?? 0)
1817
+ }
1818
+
1819
+ const rows = await sqlRaw(`SELECT id ${pending} ORDER BY received_at DESC LIMIT ?`, [limit])
1820
+ if (rows.length === 0) return result
1821
+ result.scanned = rows.length
1822
+
1823
+ const apiKey = process.env.RESEND_API_KEY
1824
+ if (!apiKey) return result
1825
+ const { putObject } = await import('./r2')
1826
+
1827
+ await Promise.all(
1828
+ rows.map(async row => {
1829
+ const id = String(row.id)
1830
+ try {
1831
+ const listing = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
1832
+ headers: { authorization: `Bearer ${apiKey}` },
1833
+ })
1834
+ if (!listing.ok) {
1835
+ result.failed += 1
1836
+ if (listing.status === 404) {
1837
+ const current = await getInboundAttachments(id).catch(() => [])
1838
+ await setInboundAttachments(id, current.map(entry => ({ ...entry, unavailable: true }))).catch(() => {})
1839
+ }
1840
+ return
1841
+ }
1842
+ const payload = (await listing.json()) as { data?: Array<Record<string, unknown>> }
1843
+ const listed = payload.data ?? []
1844
+ if (!listed.length) return
1845
+ const current = await getInboundAttachments(id).catch(() => [])
1846
+
1847
+ const kept = await Promise.all(
1848
+ listed.map(async (entry, index) => {
1849
+ if (current[index]?.key) return current[index]
1850
+ const filename = String(entry.filename ?? 'attachment')
1851
+ const contentType = entry.content_type ? String(entry.content_type) : undefined
1852
+ const source = entry.download_url ? String(entry.download_url) : ''
1853
+ const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0) }
1854
+ if (!source) return meta
1855
+ const binary = await fetch(source)
1856
+ if (!binary.ok) return meta
1857
+ const bytes = Buffer.from(await binary.arrayBuffer())
1858
+ const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
1859
+ const key = `attachments/${id}/${index}-${safeName}`
1860
+ if (!(await putObject(key, bytes, contentType))) return meta
1861
+ result.files += 1
1862
+ result.bytes += bytes.length
1863
+ return { ...meta, size: bytes.length, key }
1864
+ }),
1865
+ )
1866
+
1867
+ if (kept.some(entry => entry.key)) {
1868
+ await setInboundAttachments(id, kept)
1869
+ result.copied += 1
1870
+ }
1871
+ } catch {
1872
+ result.failed += 1
1873
+ }
1874
+ }),
1875
+ )
1876
+
1877
+ return result
1878
+ }
1879
+
1880
+ /** The files recorded against a message we sent, by the bucket keys we uploaded them to. */
1881
+ export async function getSentAttachments(
1882
+ id: string,
1883
+ ): Promise<Array<{ filename: string; size?: number; contentType?: string; key?: string }>> {
1884
+ await ensureMailSchema()
1885
+ const rows = await sqlRaw('SELECT attachments FROM mail_sent WHERE id = ?', [id])
1886
+ const parsed = parseJson<unknown>(rows[0]?.attachments, [])
1887
+ return Array.isArray(parsed) ? (parsed as Array<{ filename: string; key?: string }>) : []
1888
+ }