@novacraft-engineering/mailbox 0.4.4 → 0.4.17

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 (41) hide show
  1. package/README.md +17 -0
  2. package/app/api/mail/accessors/reset-link/route.ts +46 -0
  3. package/app/api/mail/accessors/route.ts +16 -0
  4. package/app/api/mail/emails/[id]/route.ts +11 -3
  5. package/app/api/mail/emails/route.ts +14 -4
  6. package/app/api/mail/inbox/counts/route.ts +7 -7
  7. package/app/api/mail/inbox/route.ts +23 -4
  8. package/app/api/mail/maintenance/received/route.ts +11 -6
  9. package/app/api/mail/maintenance/rejudge/route.ts +32 -0
  10. package/app/api/mail/recovery/route.ts +95 -0
  11. package/app/api/mail/recovery/verify/route.ts +29 -0
  12. package/app/api/mail/request-reset/route.ts +36 -8
  13. package/app/api/mail/scheduled/route.ts +48 -0
  14. package/app/api/mail/scheduled/run/route.ts +19 -0
  15. package/app/api/mail/send/route.ts +39 -7
  16. package/app/api/mail/threads/route.ts +1 -1
  17. package/app/api/share/[id]/download/route.ts +71 -0
  18. package/app/api/share/[id]/route.ts +11 -6
  19. package/app/globals.css +7 -0
  20. package/app/mail/AttachmentLightbox.tsx +1 -1
  21. package/app/mail/ConfirmDialog.tsx +53 -8
  22. package/app/mail/InstallGuide.tsx +133 -0
  23. package/app/mail/page.module.css +254 -1
  24. package/app/mail/page.tsx +539 -87
  25. package/app/mail/pwa.test.ts +30 -0
  26. package/app/mail/pwa.ts +25 -1
  27. package/app/mail/search.ts +17 -0
  28. package/app/share/[id]/page.tsx +61 -11
  29. package/app/share/[id]/share.module.css +19 -0
  30. package/lib/dev-auth.test.ts +15 -0
  31. package/lib/dev-auth.ts +29 -9
  32. package/lib/mail-provider.test.ts +23 -0
  33. package/lib/mail-provider.ts +39 -3
  34. package/lib/mailbox.ts +578 -51
  35. package/lib/receive.test.ts +25 -0
  36. package/lib/receive.ts +117 -17
  37. package/lib/scheduled.ts +168 -0
  38. package/lib/ses-send.ts +14 -2
  39. package/lib/share-ticket.ts +42 -0
  40. package/package.json +1 -1
  41. package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/lib/mailbox.ts CHANGED
@@ -23,6 +23,13 @@ export type InboundEmail = {
23
23
  from: string
24
24
  to: string[]
25
25
  cc: string[]
26
+ /** Written to, copied in, or neither — from the holding mailbox's point of view. */
27
+ addressed?: Addressed
28
+ /** What the spam, virus and sender-authentication checks said. */
29
+ risk?: Risk
30
+ riskReasons?: string[]
31
+ /** Held out of the inbox entirely, rather than only labelled. */
32
+ spam?: boolean
26
33
  bcc: string[]
27
34
  replyTo: string[]
28
35
  subject: string
@@ -63,7 +70,7 @@ const MAX_EVENTS = 150
63
70
  /** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
64
71
  const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
65
72
 
66
- function db() {
73
+ export function db() {
67
74
  return tursoConfigured() ? turso() : d1()
68
75
  }
69
76
 
@@ -285,11 +292,42 @@ export function ensureMailSchema(): Promise<void> {
285
292
  // Folder counts walk the whole mailbox index and Turso meters every entry; an
286
293
  // in-memory cache dies with the instance, and instances churn under polling. One
287
294
  // row here outlives them all.
295
+ // What this mailbox has learned about a correspondent by handling their mail.
296
+ // Judgement comes from here first and from fixed rules second, so the same sender
297
+ // can be trusted in one mailbox and refused in another.
298
+ `CREATE TABLE IF NOT EXISTS mail_sender_reputation (
299
+ owner TEXT NOT NULL,
300
+ domain TEXT NOT NULL,
301
+ received INTEGER NOT NULL DEFAULT 0,
302
+ trashed INTEGER NOT NULL DEFAULT 0,
303
+ marked_spam INTEGER NOT NULL DEFAULT 0,
304
+ replied INTEGER NOT NULL DEFAULT 0,
305
+ first_seen TEXT,
306
+ last_seen TEXT,
307
+ PRIMARY KEY (owner, domain)
308
+ )`,
288
309
  `CREATE TABLE IF NOT EXISTS mail_counts_cache (
289
310
  owner TEXT PRIMARY KEY,
290
311
  computed_at TEXT NOT NULL,
291
312
  counts TEXT NOT NULL
292
313
  )`,
314
+ // Sending later is ours to keep, not the provider's. SES has no notion of it and
315
+ // dropped the instruction silently; Brevo refuses outright; a Resend-shaped host
316
+ // may or may not honour it. Parking the prepared message here means the delay
317
+ // behaves the same whoever carries the mail in the end.
318
+ `CREATE TABLE IF NOT EXISTS mail_scheduled (
319
+ id TEXT PRIMARY KEY,
320
+ owner TEXT NOT NULL,
321
+ send_after TEXT NOT NULL,
322
+ status TEXT NOT NULL DEFAULT 'pending',
323
+ payload TEXT NOT NULL,
324
+ attempts INTEGER NOT NULL DEFAULT 0,
325
+ last_error TEXT,
326
+ sent_id TEXT,
327
+ created_at TEXT NOT NULL
328
+ )`,
329
+ `CREATE INDEX IF NOT EXISTS mail_scheduled_due_idx ON mail_scheduled (status, send_after)`,
330
+ `CREATE INDEX IF NOT EXISTS mail_scheduled_owner_idx ON mail_scheduled (lower(owner), send_after)`,
293
331
  // Full-text search belongs in the database, not the browser. The client used to
294
332
  // pull the mailbox down and filter it in JS, which cannot hold once the archive
295
333
  // runs to six figures.
@@ -342,6 +380,28 @@ export function ensureMailSchema(): Promise<void> {
342
380
  // allowed to fail: the second time round the column is already there.
343
381
  await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN password_is_default INTEGER NOT NULL DEFAULT 0')
344
382
  .catch(() => {})
383
+ // Whether the mailbox was actually written to, or only copied. Stored rather than
384
+ // worked out per query: matching an address inside the cc JSON means a scan, and a
385
+ // mailbox here holds six figures of mail.
386
+ await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN addressed TEXT").catch(() => {})
387
+ // What the scanners and the sender's own domain said about this message.
388
+ await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN risk TEXT").catch(() => {})
389
+ // High-confidence spam is held out of the inbox rather than merely labelled.
390
+ await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN spam INTEGER NOT NULL DEFAULT 0").catch(() => {})
391
+ await sqlRaw("CREATE INDEX IF NOT EXISTS mail_inbox_spam_idx ON mail_inbox (lower(owner), spam, received_at DESC)").catch(() => {})
392
+ await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN risk_reasons TEXT").catch(() => {})
393
+ // The row in the list follows its newest message, so the conversation carries it too.
394
+ await sqlRaw("ALTER TABLE mail_threads ADD COLUMN addressed TEXT").catch(() => {})
395
+ // Worst verdict in the conversation, so a warning cannot hide behind a later reply.
396
+ await sqlRaw("ALTER TABLE mail_threads ADD COLUMN risk TEXT").catch(() => {})
397
+ await sqlRaw("ALTER TABLE mail_threads ADD COLUMN spam_count INTEGER NOT NULL DEFAULT 0").catch(() => {})
398
+ await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_addressed_idx ON mail_inbox (lower(owner), addressed, received_at DESC)')
399
+ .catch(() => {})
400
+ // Where a reset link goes when the account's own mailbox is the thing locked.
401
+ await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_email TEXT').catch(() => {})
402
+ await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_verified INTEGER NOT NULL DEFAULT 0').catch(() => {})
403
+ // A link mailed to an unproven address must not be able to set a password.
404
+ await sqlRaw("ALTER TABLE mail_reset_tokens ADD COLUMN purpose TEXT NOT NULL DEFAULT 'reset'").catch(() => {})
345
405
  await sqlRaw("ALTER TABLE mail_webhook_events ADD COLUMN status TEXT NOT NULL DEFAULT 'working'")
346
406
  .catch(() => {})
347
407
  await sqlRaw('ALTER TABLE mail_sent ADD COLUMN attachments TEXT').catch(() => {})
@@ -397,12 +457,255 @@ export function ensureMailSchema(): Promise<void> {
397
457
  }
398
458
 
399
459
  // ── Inbox ──────────────────────────────────────────────────────
460
+
461
+ export type SenderStanding = {
462
+ received: number
463
+ trashed: number
464
+ markedSpam: number
465
+ replied: number
466
+ firstSeen: string | null
467
+ }
468
+
469
+ /** What this mailbox has done with this sender's domain before. */
470
+ export async function senderStanding(owner: string | null, domain: string): Promise<SenderStanding> {
471
+ const empty = { received: 0, trashed: 0, markedSpam: 0, replied: 0, firstSeen: null }
472
+ if (!owner || !domain) return empty
473
+ await ensureMailSchema()
474
+ const rows = await db()`
475
+ SELECT received, trashed, marked_spam, replied, first_seen FROM mail_sender_reputation
476
+ WHERE owner = ${owner.toLowerCase()} AND domain = ${domain.toLowerCase()}`
477
+ const row = rows[0]
478
+ if (!row) return empty
479
+ return {
480
+ received: Number(row.received ?? 0),
481
+ trashed: Number(row.trashed ?? 0),
482
+ markedSpam: Number(row.marked_spam ?? 0),
483
+ replied: Number(row.replied ?? 0),
484
+ firstSeen: row.first_seen == null ? null : String(row.first_seen),
485
+ }
486
+ }
487
+
488
+ /** Records one more thing this mailbox did with a sender. Every judgement feeds the next. */
489
+ export async function noteSender(
490
+ owner: string | null,
491
+ domain: string,
492
+ what: 'received' | 'trashed' | 'marked_spam' | 'replied',
493
+ ): Promise<void> {
494
+ if (!owner || !domain) return
495
+ await ensureMailSchema()
496
+ const now = nowIso()
497
+ const sql = db()
498
+ await sql`
499
+ INSERT INTO mail_sender_reputation (owner, domain, received, trashed, marked_spam, replied, first_seen, last_seen)
500
+ VALUES (${owner.toLowerCase()}, ${domain.toLowerCase()},
501
+ ${what === 'received' ? 1 : 0}, ${what === 'trashed' ? 1 : 0},
502
+ ${what === 'marked_spam' ? 1 : 0}, ${what === 'replied' ? 1 : 0}, ${now}, ${now})
503
+ ON CONFLICT (owner, domain) DO UPDATE SET
504
+ received = mail_sender_reputation.received + ${what === 'received' ? 1 : 0},
505
+ trashed = mail_sender_reputation.trashed + ${what === 'trashed' ? 1 : 0},
506
+ marked_spam = mail_sender_reputation.marked_spam + ${what === 'marked_spam' ? 1 : 0},
507
+ replied = mail_sender_reputation.replied + ${what === 'replied' ? 1 : 0},
508
+ last_seen = ${now}`
509
+ }
510
+
511
+ /** What the scanners and the sender's own domain said. */
512
+ export type Risk = 'clean' | 'suspicious' | 'spam' | 'virus'
513
+
514
+ export type RiskSignals = {
515
+ spam?: string | null
516
+ virus?: string | null
517
+ spf?: string | null
518
+ dkim?: string | null
519
+ dmarc?: string | null
520
+ /** The message itself, for the tells authentication cannot see. */
521
+ from?: string | null
522
+ replyTo?: string[] | null
523
+ subject?: string | null
524
+ text?: string | null
525
+ }
526
+
527
+ /** Defaults only. Each is overridable per deployment, so a list can change without a
528
+ * release — metroperil can drop a word its own trade uses every day. */
529
+ const FREE_MAIL_DEFAULT = new Set([
530
+ 'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'hotmail.com', 'outlook.com',
531
+ 'live.com', 'aol.com', 'protonmail.com', 'proton.me', 'mail.com', 'gmx.com', 'yandex.com',
532
+ 'icloud.com', 'zoho.com', 'inbox.lv', 'consultant.com', 'qq.com', '163.com',
533
+ ])
534
+
535
+ const THROWAWAY_TLDS_DEFAULT = new Set([
536
+ 'xyz', 'top', 'buzz', 'click', 'link', 'work', 'gq', 'cf', 'ml', 'tk', 'ga',
537
+ 'loan', 'men', 'date', 'racing', 'win', 'stream', 'download', 'review', 'country', 'kim',
538
+ ])
539
+
540
+ /** The shape of an advance-fee approach. Counted, never single-word: one alone is innocent. */
541
+ const SCAM_PHRASES_DEFAULT = [
542
+ 'next of kin', 'sole beneficiary', 'beneficiary', 'late client', 'deceased client',
543
+ 'unclaimed', 'inheritance', 'died without', 'without a will', 'fund transfer',
544
+ 'business proposal', 'strictly confidential', 'bank draft', 'consignment',
545
+ 'compensation fund', 'lottery', 'winning notification', 'atm card', 'western union',
546
+ ]
547
+
548
+ /** The registrable domain behind an address, for reputation to be keyed on. */
549
+ export const senderDomainOf = (address: string): string => registrable(domainOf(address))
550
+
551
+ const domainOf = (address: string): string => {
552
+ const angled = address.match(/<([^>]+)>/)
553
+ const bare = (angled ? angled[1] : address).trim().toLowerCase()
554
+ return bare.split('@').pop() ?? ''
555
+ }
556
+
557
+ /** example.co.uk and example.com both reduce to the name somebody actually registered. */
558
+ const registrable = (host: string): string => {
559
+ const parts = host.split('.').filter(Boolean)
560
+ if (parts.length <= 2) return parts.join('.')
561
+ const twoLevel = /^(co|com|org|net|gov|ac|edu|ltd|plc)\.[a-z]{2}$/.test(parts.slice(-2).join('.'))
562
+ return parts.slice(twoLevel ? -3 : -2).join('.')
563
+ }
564
+
565
+ const failed = (verdict: string | null | undefined): boolean =>
566
+ typeof verdict === 'string' && /^(fail|softfail|permerror)$/i.test(verdict.trim())
567
+
568
+ const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<string> => {
569
+ const parsed = (raw ?? '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
570
+ return parsed.length ? new Set(parsed) : new Set(fallback)
571
+ }
572
+
573
+ // Read per call, so a deployment can change any of them without a release.
574
+ const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
575
+ const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
576
+ const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
577
+
578
+ /** Weight at which a message stops being labelled and is held out of the inbox instead. */
579
+ const quarantineAt = () => Number(process.env.MAIL_SPAM_THRESHOLD ?? 6)
580
+
581
+ /** Below this nothing is said at all. One small oddity is not a case. */
582
+ const flagAt = () => Number(process.env.MAIL_SUSPICION_THRESHOLD ?? 3)
583
+
584
+ export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quarantine: boolean }
585
+
586
+ /**
587
+ * What this mailbox knows, then what is true of the message. The standing a sender has
588
+ * built here leads: somebody you have written back to is not spam because their subject
589
+ * shouts, and somebody whose mail you have binned repeatedly does not get the benefit of
590
+ * the doubt again. The fixed rules only decide the cases with no history to go on, and
591
+ * every one of their lists can be changed per deployment without a release.
592
+ */
593
+ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
594
+ const reasons: string[] = []
595
+ let score = 0
596
+ const add = (weight: number, why: string) => { score += weight; reasons.push(why) }
597
+
598
+ if (/^fail$/i.test((signals.virus ?? '').trim())) {
599
+ return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
600
+ }
601
+
602
+ // Trust is earned by being written back to, never by volume alone: a sender whose mail
603
+ // arrives forty times and is binned every time has not earned anything.
604
+ const trusted = standing.replied > 0 && standing.markedSpam === 0
605
+ if (standing.markedSpam > 0) {
606
+ add(4 + Math.min(standing.markedSpam, 4),
607
+ `You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`)
608
+ } else if (standing.trashed >= 3 && standing.replied === 0) {
609
+ add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`)
610
+ }
611
+
612
+ if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message')
613
+
614
+ const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
615
+ // Heavy, but not enough on its own to hide a message: mail forwarded through a list
616
+ // breaks alignment and fails DMARC while being perfectly legitimate. It warns loudly;
617
+ // it takes a second finding to put a message out of sight.
618
+ if (failed(signals.dmarc)) add(4, 'The sending domain says this message is not from them (DMARC failed)')
619
+ else if (!authenticated) {
620
+ if (failed(signals.spf)) add(2, 'The sending server is not authorised by that domain (SPF failed)')
621
+ if (failed(signals.dkim)) add(2, 'The signature does not match the sending domain (DKIM failed)')
622
+ }
623
+
624
+ const fromDomain = registrable(domainOf(signals.from ?? ''))
625
+ const replyDomains = (signals.replyTo ?? [])
626
+ .map(entry => registrable(domainOf(entry)))
627
+ .filter(entry => entry && entry !== fromDomain)
628
+ const free = freeProviders()
629
+ const freeReply = replyDomains.find(entry => free.has(entry))
630
+ if (freeReply && fromDomain && !free.has(fromDomain)) {
631
+ add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`)
632
+ } else if (replyDomains.length) {
633
+ add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
634
+ }
635
+
636
+ const tld = fromDomain.split('.').pop() ?? ''
637
+ if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`)
638
+ if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits')
639
+
640
+ const subject = (signals.subject ?? '').trim()
641
+ const letters = subject.replace(/[^A-Za-z]/g, '')
642
+ if (letters.length >= 12 && letters === letters.toUpperCase()) add(1, 'The subject is written entirely in capitals')
643
+
644
+ const body = (signals.text ?? '').toLowerCase()
645
+ const hits = scamPhrases().filter(phrase => body.includes(phrase))
646
+ if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`)
647
+ else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
648
+
649
+ // Never heard from before is not suspicious by itself — everyone writes once for the
650
+ // first time — but it is what turns a couple of small oddities into a pattern.
651
+ if (!trusted && standing.received <= 1 && score > 0) add(1, 'This is the first message from this sender')
652
+
653
+ // Someone this mailbox corresponds with is forgiven the small stuff; only findings heavy
654
+ // enough to stand on their own still count against them.
655
+ const limit = quarantineAt()
656
+ if (trusted && score < limit) return { risk: 'clean', reasons: [], score: 0, quarantine: false }
657
+
658
+ // One small oddity is not a case to answer. A subject in capitals from somebody writing
659
+ // for the first time is a stranger in a hurry, not a scam, and saying otherwise every
660
+ // time teaches the reader to ignore the warning.
661
+ if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
662
+
663
+ const quarantine = score >= limit
664
+ return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
665
+ }
666
+
667
+
668
+ /** How the mailbox came to hold a message, from that mailbox's own point of view. */
669
+ export type Addressed = 'direct' | 'copied' | 'other'
670
+
671
+ const bare = (raw: string): string => {
672
+ const angled = raw.match(/<([^>]+)>/)
673
+ return (angled ? angled[1] : raw).trim().toLowerCase()
674
+ }
675
+
676
+ /**
677
+ * Written to, copied in, or neither. The third case is real and common — a blind copy, a
678
+ * distribution list, an alias, or mail caught by the shared address — and calling it a
679
+ * copy would be a guess dressed as a fact, so it gets its own answer.
680
+ *
681
+ * Judged against the mailbox that holds the message, never the person reading it: an
682
+ * administrator reading everyone's mail must not be told they were copied on someone
683
+ * else's.
684
+ */
685
+ export function classifyAddressed(
686
+ owner: string | null | undefined,
687
+ to: string[],
688
+ cc: string[],
689
+ ): Addressed {
690
+ const seat = (owner ?? '').trim().toLowerCase()
691
+ if (!seat) return 'other'
692
+ if (to.some(entry => bare(entry) === seat)) return 'direct'
693
+ if (cc.some(entry => bare(entry) === seat)) return 'copied'
694
+ return 'other'
695
+ }
696
+
400
697
  function mapInbound(row: Record<string, unknown>): InboundEmail {
401
698
  return {
402
699
  id: String(row.id),
403
700
  from: (row.from_addr as string) ?? '',
404
701
  to: parseArray(row.to_addrs),
405
702
  cc: parseArray(row.cc),
703
+ addressed: (['direct', 'copied', 'other'].includes(String(row.addressed))
704
+ ? String(row.addressed)
705
+ : classifyAddressed(row.owner == null ? null : String(row.owner), parseArray(row.to_addrs), parseArray(row.cc))) as Addressed,
706
+ risk: (['clean', 'suspicious', 'spam', 'virus'].includes(String(row.risk)) ? String(row.risk) : 'clean') as Risk,
707
+ riskReasons: parseJson<string[]>(row.risk_reasons, []),
708
+ spam: Boolean(row.spam),
406
709
  bcc: parseArray(row.bcc),
407
710
  replyTo: parseArray(row.reply_to),
408
711
  subject: (row.subject as string) ?? '',
@@ -444,13 +747,15 @@ export type InboxPage = {
444
747
  export async function searchInbox(options: {
445
748
  text?: string
446
749
  owner?: string
447
- folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
750
+ folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
448
751
  unread?: boolean
449
752
  starred?: boolean
450
753
  hasAttachment?: boolean
451
754
  label?: string
452
755
  from?: string
453
756
  to?: string
757
+ /** direct | copied | other, or 'not-copied' to leave copies out. */
758
+ addressed?: string
454
759
  limit?: number
455
760
  offset?: number
456
761
  cursor?: string | null
@@ -485,16 +790,23 @@ export async function searchInbox(options: {
485
790
  if (options.label) { where.push('m.labels LIKE ?'); args.push(`%${options.label}%`) }
486
791
  if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
487
792
  if (options.to) { where.push('lower(m.to_addrs) LIKE ?'); args.push(`%${options.to.toLowerCase()}%`) }
793
+ // Filtered in the query, not in the browser: a client-side pass would only ever narrow
794
+ // the page already loaded, which on a mailbox of six figures reads as a broken filter.
795
+ if (options.addressed === 'not-copied') where.push("COALESCE(m.addressed, 'other') <> 'copied'")
796
+ else if (options.addressed) { where.push("COALESCE(m.addressed, 'other') = ?"); args.push(options.addressed) }
488
797
 
489
798
  // A snoozed message is only out of the inbox while its time is still ahead; the clause
490
799
  // does the waking, so nothing has to run on a timer.
491
800
  const nowIso = new Date().toISOString()
492
801
  const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
493
- if (options.folder === 'trash') where.push('m.trashed = 1')
494
- else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0')
495
- else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0')
496
- else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0'); args.push(nowIso) }
497
- else if (options.folder === 'inbox') { where.push(`m.archived = 0 AND m.trashed = 0 AND ${awake}`); args.push(nowIso) }
802
+ // Quarantined mail belongs to exactly one folder and appears in no other, or holding it
803
+ // back would be pointless — it would still be sitting in the inbox under a label.
804
+ if (options.folder === 'spam') where.push('m.spam = 1 AND m.trashed = 0')
805
+ else if (options.folder === 'trash') where.push('m.trashed = 1')
806
+ else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0 AND m.spam = 0')
807
+ else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0 AND m.spam = 0')
808
+ else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0 AND m.spam = 0'); args.push(nowIso) }
809
+ else if (options.folder === 'inbox') { where.push(`m.archived = 0 AND m.trashed = 0 AND m.spam = 0 AND ${awake}`); args.push(nowIso) }
498
810
 
499
811
  const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
500
812
 
@@ -524,7 +836,8 @@ export async function searchInbox(options: {
524
836
  'contentType', json_extract(value, '$.contentType'),
525
837
  'size', json_extract(value, '$.size')))
526
838
  FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
527
- m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id
839
+ m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id,
840
+ m.addressed, m.risk, m.risk_reasons, m.spam
528
841
  FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
529
842
  ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
530
843
  cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
@@ -570,6 +883,7 @@ export type FolderTally = {
570
883
  starred: number
571
884
  archived: number
572
885
  trashed: number
886
+ spam: number
573
887
  snoozed: number
574
888
  }
575
889
 
@@ -591,9 +905,13 @@ const COUNTS_CACHE_MS = 5 * 60 * 1000
591
905
 
592
906
  /** countFolders through a durable cache: one row read when fresh, a full scan only when stale. */
593
907
  export async function invalidateCounts(owner: string | null | undefined): Promise<void> {
594
- if (!owner) return
595
908
  try {
596
- await db()`DELETE FROM mail_counts_cache WHERE owner = ${owner.toLowerCase()}`
909
+ const sql = db()
910
+ // The '*all' row sums every mailbox, so one person's mail moving makes it wrong too.
911
+ // Dropping only the owner's row left the all-inboxes view quoting figures from before
912
+ // the delete, and an owner of null dropped nothing at all.
913
+ if (owner) await sql`DELETE FROM mail_counts_cache WHERE owner = ${owner.toLowerCase()}`
914
+ await sql`DELETE FROM mail_counts_cache WHERE owner = '*all'`
597
915
  } catch {
598
916
  }
599
917
  }
@@ -630,12 +948,13 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
630
948
  const rows = await tagged(
631
949
  sql,
632
950
  `SELECT
633
- SUM(CASE WHEN archived = 0 AND trashed = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
634
- SUM(CASE WHEN archived = 0 AND trashed = 0 AND read = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
635
- SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
636
- SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
951
+ SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
952
+ SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 AND read = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
953
+ SUM(CASE WHEN starred = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS starred,
954
+ SUM(CASE WHEN archived = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS archived,
637
955
  SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
638
- SUM(CASE WHEN snoozed_until > ? AND trashed = 0 THEN 1 ELSE 0 END) AS snoozed
956
+ SUM(CASE WHEN spam = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS spam,
957
+ SUM(CASE WHEN snoozed_until > ? AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS snoozed
639
958
  FROM mail_inbox ${scope}`,
640
959
  [nowIso, nowIso, nowIso, ...args],
641
960
  )
@@ -656,6 +975,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
656
975
  SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
657
976
  SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
658
977
  SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
978
+ 0 AS spam,
659
979
  SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
660
980
  FROM mail_threads WHERE owner = ?`,
661
981
  [nowIso, nowIso, nowIso, owner.toLowerCase()],
@@ -668,6 +988,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
668
988
  starred: threadValue('starred'),
669
989
  archived: threadValue('archived'),
670
990
  trashed: threadValue('trashed'),
991
+ spam: threadValue('spam'),
671
992
  snoozed: threadValue('snoozed'),
672
993
  }
673
994
  }
@@ -678,6 +999,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
678
999
  starred: value('starred'),
679
1000
  archived: value('archived'),
680
1001
  trashed: value('trashed'),
1002
+ spam: value('spam'),
681
1003
  snoozed: value('snoozed'),
682
1004
  conversations,
683
1005
  }
@@ -689,10 +1011,10 @@ export async function readInbox(filter?: { owner?: string }): Promise<InboundEma
689
1011
  const owner = filter?.owner?.trim().toLowerCase()
690
1012
  const rows = owner
691
1013
  ? await sql`
692
- 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
1014
+ 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, addressed, risk, risk_reasons, spam
693
1015
  FROM mail_inbox WHERE lower(owner) = ${owner} ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
694
1016
  : await sql`
695
- 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
1017
+ 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, addressed, risk, risk_reasons, spam
696
1018
  FROM mail_inbox ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
697
1019
  return rows.map(mapInbound)
698
1020
  }
@@ -710,6 +1032,7 @@ export type ThreadRow = {
710
1032
  inboxCount: number
711
1033
  archivedCount: number
712
1034
  trashedCount: number
1035
+ spamCount: number
713
1036
  attachCount: number
714
1037
  senders: string[]
715
1038
  snippet: string
@@ -748,12 +1071,14 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
748
1071
  const owner = ownerRaw.toLowerCase()
749
1072
  const agg = await sql`
750
1073
  SELECT COUNT(*) AS n,
751
- SUM(CASE WHEN read = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS unread,
752
- SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
753
- SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
754
- SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
1074
+ SUM(CASE WHEN read = 0 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS unread,
1075
+ SUM(CASE WHEN starred = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS starred,
1076
+ SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS inbox,
1077
+ SUM(CASE WHEN archived = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS archived,
1078
+ SUM(CASE WHEN spam = 1 THEN 1 ELSE 0 END) AS spam,
755
1079
  SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
756
1080
  SUM(CASE WHEN attachments IS NOT NULL AND attachments NOT IN ('', '[]') THEN 1 ELSE 0 END) AS attach,
1081
+ MAX(CASE risk WHEN 'virus' THEN 3 WHEN 'spam' THEN 2 WHEN 'suspicious' THEN 1 ELSE 0 END) AS worst_risk,
757
1082
  MIN(received_at) AS first_at, MAX(received_at) AS latest_at
758
1083
  FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
759
1084
  const total = Number(agg[0]?.n ?? 0)
@@ -762,7 +1087,7 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
762
1087
  return
763
1088
  }
764
1089
  const latest = await sql`
765
- SELECT id, subject, COALESCE(snippet, substr(COALESCE(body_text, ''), 1, 320)) AS snippet
1090
+ SELECT id, subject, addressed, COALESCE(snippet, substr(COALESCE(body_text, ''), 1, 320)) AS snippet
766
1091
  FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
767
1092
  ORDER BY received_at DESC, id DESC LIMIT 1`
768
1093
  const members = await sql`
@@ -778,19 +1103,22 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
778
1103
  const head = latest[0]
779
1104
  await sql`
780
1105
  INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
781
- unread_count, starred_count, inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels)
1106
+ unread_count, starred_count, inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, addressed)
782
1107
  VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
783
1108
  ${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
784
1109
  ${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
785
- ${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].attach ?? 0)},
786
- ${JSON.stringify(senders)}, ${String(head?.snippet ?? '')}, ${JSON.stringify([...labels])})
1110
+ ${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].spam ?? 0)}, ${Number(agg[0].attach ?? 0)},
1111
+ ${JSON.stringify(senders)}, ${String(head?.snippet ?? '')}, ${JSON.stringify([...labels])},
1112
+ ${head?.addressed == null ? null : String(head.addressed)},
1113
+ ${['clean', 'suspicious', 'spam', 'virus'][Number(agg[0]?.worst_risk ?? 0)] ?? 'clean'})
787
1114
  ON CONFLICT (owner, thread_id) DO UPDATE SET
788
1115
  subject_key = excluded.subject_key, subject = excluded.subject, first_at = excluded.first_at,
789
1116
  latest_at = excluded.latest_at, latest_id = excluded.latest_id, count = excluded.count,
790
1117
  unread_count = excluded.unread_count, starred_count = excluded.starred_count,
791
1118
  inbox_count = excluded.inbox_count, archived_count = excluded.archived_count,
792
- trashed_count = excluded.trashed_count, attach_count = excluded.attach_count,
793
- senders = excluded.senders, snippet = excluded.snippet, labels = excluded.labels`
1119
+ trashed_count = excluded.trashed_count, spam_count = excluded.spam_count, attach_count = excluded.attach_count,
1120
+ senders = excluded.senders, snippet = excluded.snippet, labels = excluded.labels,
1121
+ addressed = excluded.addressed, risk = excluded.risk`
794
1122
  }
795
1123
 
796
1124
  /** Thread the message and refresh its summary; never lets a threading fault fail a write. */
@@ -831,7 +1159,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
831
1159
  }
832
1160
  }
833
1161
 
834
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
1162
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
835
1163
 
836
1164
  /** The newest conversations in a folder: one row each, already summarised. */
837
1165
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
@@ -841,7 +1169,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
841
1169
  const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
842
1170
  const nowIso = new Date().toISOString()
843
1171
  const predicate =
844
- folder === 'archive' ? 'archived_count > 0'
1172
+ folder === 'spam' ? 'spam_count > 0'
1173
+ : folder === 'archive' ? 'archived_count > 0'
845
1174
  : folder === 'trash' ? 'trashed_count > 0'
846
1175
  : folder === 'starred' ? 'starred_count > 0'
847
1176
  : folder === 'snoozed' ? 'snoozed_until > ?'
@@ -861,8 +1190,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
861
1190
  SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
862
1191
  SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
863
1192
  SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
864
- SUM(trashed_count) AS trashed_count, SUM(attach_count) AS attach_count,
865
- senders, snippet, labels, snoozed_until
1193
+ SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1194
+ senders, snippet, labels, snoozed_until, addressed, risk
866
1195
  FROM mail_threads
867
1196
  GROUP BY thread_id
868
1197
  HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
@@ -870,7 +1199,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
870
1199
  [...folderArgs, ...cursorArgs, limit])
871
1200
  : await tagged(db(), `
872
1201
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
873
- inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
1202
+ inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, snoozed_until, addressed, risk
874
1203
  FROM mail_threads WHERE owner = ? AND ${predicate}
875
1204
  ${cursorClause ? `AND ${cursorClause}` : ''}
876
1205
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
@@ -889,11 +1218,14 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
889
1218
  inboxCount: Number(row.inbox_count ?? 0),
890
1219
  archivedCount: Number(row.archived_count ?? 0),
891
1220
  trashedCount: Number(row.trashed_count ?? 0),
1221
+ spamCount: Number(row.spam_count ?? 0),
892
1222
  attachCount: Number(row.attach_count ?? 0),
893
1223
  senders: parseJson<string[]>(row.senders, []),
894
1224
  snippet: String(row.snippet ?? ''),
895
1225
  labels: parseJson<string[]>(row.labels, []),
896
1226
  snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
1227
+ addressed: (['direct', 'copied', 'other'].includes(String(row.addressed)) ? String(row.addressed) : 'direct') as Addressed,
1228
+ risk: (['clean', 'suspicious', 'spam', 'virus'].includes(String(row.risk)) ? String(row.risk) : 'clean') as Risk,
897
1229
  }))
898
1230
  return { rows: mapped, nextCursor }
899
1231
  }
@@ -963,13 +1295,107 @@ export async function appendInbound(
963
1295
  await ensureMailSchema()
964
1296
  const sql = db()
965
1297
  await sql`
966
- 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)
967
- 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)})
1298
+ 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, addressed, risk, risk_reasons, spam)
1299
+ 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)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0})
968
1300
  ON CONFLICT (id) DO NOTHING`
969
1301
  await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
970
1302
  await invalidateCounts(email.owner)
971
1303
  }
972
1304
 
1305
+ /**
1306
+ * Fill in what a hollow row is missing, for messages stored before the body could be
1307
+ * fetched. Only ever writes content that is absent — read, starred, archived, trashed,
1308
+ * labels and owner are the reader's, not the repair's, and a row that already has a body
1309
+ * is left exactly as it is.
1310
+ */
1311
+ export async function rejudgeStored(options: { before?: string; limit?: number } = {}): Promise<{
1312
+ scanned: number
1313
+ changed: number
1314
+ quarantined: number
1315
+ cursor: string | null
1316
+ }> {
1317
+ await ensureMailSchema()
1318
+ const sql = db()
1319
+ const limit = Math.min(Math.max(options.limit ?? 200, 1), 500)
1320
+ const before = options.before ?? '9999-12-31'
1321
+ const rows = await sql`
1322
+ SELECT id, owner, from_addr, reply_to, subject, body_text, headers, received_at,
1323
+ read, starred, archived, trashed, risk, spam
1324
+ FROM mail_inbox
1325
+ WHERE received_at < ${before}
1326
+ ORDER BY received_at DESC
1327
+ LIMIT ${limit}`
1328
+
1329
+ const result = { scanned: rows.length, changed: 0, quarantined: 0, cursor: null as string | null }
1330
+ if (rows.length === 0) return result
1331
+ result.cursor = String(rows[rows.length - 1].received_at ?? '')
1332
+
1333
+ const standings = new Map<string, SenderStanding>()
1334
+ const touchedOwners = new Set<string>()
1335
+ for (const row of rows) {
1336
+ const owner = String(row.owner ?? '')
1337
+ const senderDomain = senderDomainOf(String(row.from_addr ?? ''))
1338
+ const key = `${owner.toLowerCase()}\u0000${senderDomain}`
1339
+ let standing = standings.get(key)
1340
+ if (!standing) {
1341
+ standing = await senderStanding(owner, senderDomain)
1342
+ standings.set(key, standing)
1343
+ }
1344
+
1345
+ const headers = parseJson<Record<string, unknown>>(row.headers, {})
1346
+ const authHeader = headerString(headers, 'authentication-results').toLowerCase()
1347
+ const mechanism = (name: string) => authHeader.match(new RegExp(`${name}=(\\w+)`))?.[1] ?? null
1348
+ const verdict = judgeMessage({
1349
+ spam: null,
1350
+ virus: null,
1351
+ spf: mechanism('spf'),
1352
+ dkim: mechanism('dkim'),
1353
+ dmarc: mechanism('dmarc'),
1354
+ from: String(row.from_addr ?? ''),
1355
+ replyTo: parseJson<string[]>(row.reply_to, []),
1356
+ subject: String(row.subject ?? ''),
1357
+ text: row.body_text == null ? null : String(row.body_text),
1358
+ }, standing)
1359
+
1360
+ // A message the reader has already read, starred, filed or binned stays exactly where
1361
+ // they put it — back-fill may label it, never move it out from under them.
1362
+ const untouched = !Number(row.read) && !Number(row.starred) && !Number(row.archived) && !Number(row.trashed)
1363
+ const quarantine = verdict.quarantine && untouched
1364
+ if (String(row.risk ?? 'clean') === verdict.risk && Boolean(Number(row.spam)) === quarantine) continue
1365
+
1366
+ await sql`
1367
+ UPDATE mail_inbox
1368
+ SET risk = ${verdict.risk}, risk_reasons = ${JSON.stringify(verdict.reasons)}, spam = ${quarantine ? 1 : 0}
1369
+ WHERE id = ${String(row.id)}`
1370
+ await rethreadAfterChange(String(row.id))
1371
+ result.changed += 1
1372
+ if (quarantine) result.quarantined += 1
1373
+ if (owner) touchedOwners.add(owner)
1374
+ }
1375
+
1376
+ for (const owner of touchedOwners) await invalidateCounts(owner)
1377
+ return result
1378
+ }
1379
+
1380
+ export async function repairInbound(
1381
+ email: Pick<InboundEmail, 'id' | 'html' | 'text' | 'headers' | 'attachments'>,
1382
+ ): Promise<boolean> {
1383
+ await ensureMailSchema()
1384
+ const sql = db()
1385
+ const rows = await sql`
1386
+ UPDATE mail_inbox SET
1387
+ html = CASE WHEN coalesce(html, '') = '' THEN ${email.html} ELSE html END,
1388
+ body_text = CASE WHEN coalesce(body_text, '') = '' THEN ${email.text} ELSE body_text END,
1389
+ headers = CASE WHEN coalesce(headers, '') IN ('', '{}') THEN ${JSON.stringify(email.headers)} ELSE headers END,
1390
+ attachments = CASE WHEN coalesce(attachments, '') IN ('', '[]') THEN ${JSON.stringify(email.attachments)} ELSE attachments END,
1391
+ attach_meta = CASE WHEN coalesce(attach_meta, '') IN ('', '[]') THEN ${attachMeta(email.attachments)} ELSE attach_meta END,
1392
+ snippet = CASE WHEN coalesce(snippet, '') = '' THEN ${listSnippet(email.text)} ELSE snippet END,
1393
+ thread_meta = CASE WHEN coalesce(thread_meta, '') IN ('', '{}') THEN ${threadMeta(email.headers)} ELSE thread_meta END
1394
+ WHERE id = ${email.id}
1395
+ RETURNING id`
1396
+ return rows.length > 0
1397
+ }
1398
+
973
1399
  export async function getInboundSource(
974
1400
  id: string,
975
1401
  ): Promise<{ owner: string | null; attachments: Array<Record<string, unknown>> } | null> {
@@ -1217,13 +1643,44 @@ export async function listInboundWithAttachments(owner: string | null): Promise<
1217
1643
  export async function setInboxOwner(id: string, owner: string | null): Promise<void> {
1218
1644
  const sql = db()
1219
1645
  const before = await sql`SELECT owner, thread_id FROM mail_inbox WHERE id = ${id}`
1220
- await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL WHERE id = ${id}`
1646
+ const moved = await sql`SELECT to_addrs, cc FROM mail_inbox WHERE id = ${id}`
1647
+ const nowAddressed = classifyAddressed(owner, parseArray(moved[0]?.to_addrs), parseArray(moved[0]?.cc))
1648
+ // A message that was a copy in one mailbox can be direct mail in another.
1649
+ await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL, addressed = ${nowAddressed} WHERE id = ${id}`
1650
+ // Both sides change: the mailbox it left and the one it arrived in.
1651
+ await invalidateCounts(before[0]?.owner == null ? null : String(before[0].owner))
1652
+ await invalidateCounts(owner)
1221
1653
  await rethreadAfterChange(id, before[0]?.owner == null ? null : String(before[0].owner), before[0]?.thread_id == null ? null : String(before[0].thread_id))
1222
1654
  }
1223
1655
 
1224
1656
  export async function markInboundRead(id: string): Promise<void> {
1225
1657
  const sql = db()
1226
1658
  await sql`UPDATE mail_inbox SET read = 1 WHERE id = ${id}`
1659
+ const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1660
+ await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1661
+ await rethreadAfterChange(id)
1662
+ }
1663
+
1664
+ /**
1665
+ * Moves a message in or out of quarantine and remembers the decision. This is the loop:
1666
+ * what the reader does with a sender's mail decides how the next one is treated, so the
1667
+ * same message can be spam in one mailbox and ordinary correspondence in another.
1668
+ */
1669
+ export async function setInboundSpam(id: string, spam: boolean): Promise<void> {
1670
+ await ensureMailSchema()
1671
+ const sql = db()
1672
+ const rows = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
1673
+ const row = rows[0]
1674
+ // A reader who says "not spam" has overruled the judgement, so the warning goes with the
1675
+ // quarantine — leaving the badge on would argue with them every time they open it.
1676
+ await (spam
1677
+ ? sql`UPDATE mail_inbox SET spam = 1, archived = 0, trashed = 0 WHERE id = ${id}`
1678
+ : sql`UPDATE mail_inbox SET spam = 0, archived = 0, trashed = 0, risk = 'clean', risk_reasons = '[]' WHERE id = ${id}`)
1679
+ if (row?.owner) {
1680
+ await noteSender(String(row.owner), senderDomainOf(String(row.from_addr ?? '')), spam ? 'marked_spam' : 'replied')
1681
+ .catch(() => {})
1682
+ }
1683
+ await invalidateCounts(row?.owner == null ? null : String(row.owner))
1227
1684
  await rethreadAfterChange(id)
1228
1685
  }
1229
1686
 
@@ -1233,7 +1690,12 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
1233
1690
  if (flags.starred !== undefined) await sql`UPDATE mail_inbox SET starred = ${flags.starred} WHERE id = ${id}`
1234
1691
  if (flags.archived !== undefined) await sql`UPDATE mail_inbox SET archived = ${flags.archived} WHERE id = ${id}`
1235
1692
  if (flags.trashed !== undefined) await sql`UPDATE mail_inbox SET trashed = ${flags.trashed} WHERE id = ${id}`
1236
- const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1693
+ const owned = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
1694
+ // Binning a sender's mail counts against them; it is the commonest way a reader says
1695
+ // "not this one" without ever pressing a button marked spam.
1696
+ if (flags.trashed === true && owned[0]?.owner) {
1697
+ await noteSender(String(owned[0].owner), senderDomainOf(String(owned[0].from_addr ?? '')), 'trashed').catch(() => {})
1698
+ }
1237
1699
  await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1238
1700
  await rethreadAfterChange(id)
1239
1701
  }
@@ -1299,6 +1761,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
1299
1761
  export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
1300
1762
  const sql = db()
1301
1763
  await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
1764
+ const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1765
+ await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1302
1766
  }
1303
1767
 
1304
1768
  // ── Sent-mail flags (star / archive / trash on Resend-sent emails) ──
@@ -1484,6 +1948,9 @@ export type MailAccount = {
1484
1948
  hasPassword: boolean
1485
1949
  createdAt: string | null
1486
1950
  invitedBy: string | null
1951
+ /** Outside address that can receive a reset link. Only usable once proven. */
1952
+ recoveryEmail: string | null
1953
+ recoveryVerified: boolean
1487
1954
  }
1488
1955
 
1489
1956
  function mapAccount(row: Record<string, unknown>): MailAccount {
@@ -1496,27 +1963,29 @@ function mapAccount(row: Record<string, unknown>): MailAccount {
1496
1963
  hasPassword: Boolean(row.has_password),
1497
1964
  createdAt: isoOrNull(row.created_at),
1498
1965
  invitedBy: (row.invited_by as string) ?? null,
1966
+ recoveryEmail: (row.recovery_email as string) ?? null,
1967
+ recoveryVerified: Boolean(Number(row.recovery_verified ?? 0)),
1499
1968
  }
1500
1969
  }
1501
1970
 
1502
1971
  export async function listAccounts(): Promise<MailAccount[]> {
1503
1972
  await ensureMailSchema()
1504
1973
  const sql = db()
1505
- 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`
1974
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
1506
1975
  return rows.map(mapAccount)
1507
1976
  }
1508
1977
 
1509
1978
  export async function getAccount(email: string): Promise<MailAccount | null> {
1510
1979
  await ensureMailSchema()
1511
1980
  const sql = db()
1512
- 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()}`
1981
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1513
1982
  return rows[0] ? mapAccount(rows[0]) : null
1514
1983
  }
1515
1984
 
1516
1985
  export async function getAccountByAddress(address: string): Promise<MailAccount | null> {
1517
1986
  await ensureMailSchema()
1518
1987
  const sql = db()
1519
- 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()}`
1988
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
1520
1989
  return rows[0] ? mapAccount(rows[0]) : null
1521
1990
  }
1522
1991
 
@@ -1552,6 +2021,27 @@ export async function deleteAccount(email: string): Promise<void> {
1552
2021
  await sql`DELETE FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1553
2022
  }
1554
2023
 
2024
+ /** Record a recovery address as claimed but unproven. Re-saving the same one re-arms it. */
2025
+ export async function setRecoveryEmail(email: string, recovery: string | null): Promise<void> {
2026
+ await ensureMailSchema()
2027
+ const sql = db()
2028
+ const normalized = recovery ? recovery.trim().toLowerCase() : null
2029
+ await sql`
2030
+ UPDATE mail_accounts SET recovery_email = ${normalized}, recovery_verified = 0
2031
+ WHERE email = ${email.trim().toLowerCase()}`
2032
+ }
2033
+
2034
+ /** Prove the address: only the one currently on the account, so a stale link cannot land. */
2035
+ export async function markRecoveryVerified(email: string, recovery: string): Promise<boolean> {
2036
+ await ensureMailSchema()
2037
+ const sql = db()
2038
+ const rows = await sql`
2039
+ UPDATE mail_accounts SET recovery_verified = 1
2040
+ WHERE email = ${email.trim().toLowerCase()} AND lower(recovery_email) = ${recovery.trim().toLowerCase()}
2041
+ RETURNING email`
2042
+ return rows.length > 0
2043
+ }
2044
+
1555
2045
  // ── Sent-mail archive (our own copy, independent of any provider) ─
1556
2046
  export type SentMessage = {
1557
2047
  id: string
@@ -1569,6 +2059,8 @@ export type SentMessage = {
1569
2059
  attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
1570
2060
  /** How many files the list should raise a paperclip for, counted the way the inbox counts. */
1571
2061
  attachmentCount?: number
2062
+ /** Sent by the app itself — an invite, a reset, an auto-reply — rather than by a person. */
2063
+ isAuto?: boolean
1572
2064
  }
1573
2065
 
1574
2066
  export async function recordSentMessage(message: SentMessage): Promise<void> {
@@ -1665,11 +2157,12 @@ export async function readSentArchive(options: {
1665
2157
  sharedAddress?: string | null
1666
2158
  query?: ParsedQuery | null
1667
2159
  limit?: number
2160
+ includeAuto?: boolean
1668
2161
  } = {}): Promise<SentMessage[]> {
1669
2162
  await ensureMailSchema()
1670
2163
  const sql = db()
1671
2164
  const limit = Math.min(Math.max(options.limit ?? 500, 1), 1000)
1672
- const where: string[] = ['coalesce(m.is_auto, 0) = 0']
2165
+ const where: string[] = options.includeAuto ? [] : ['coalesce(m.is_auto, 0) = 0']
1673
2166
  const args: unknown[] = []
1674
2167
 
1675
2168
  const owner = options.ownerAddress?.trim().toLowerCase()
@@ -1691,6 +2184,7 @@ export async function readSentArchive(options: {
1691
2184
  sql,
1692
2185
  `SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
1693
2186
  json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
2187
+ coalesce(m.is_auto, 0) AS is_auto,
1694
2188
  ${textColumn} AS text
1695
2189
  FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
1696
2190
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
@@ -1710,12 +2204,18 @@ export async function readSentArchive(options: {
1710
2204
  createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1711
2205
  lastEvent: (row.last_event as string) ?? null,
1712
2206
  attachmentCount: Number(row.attach_count ?? 0),
2207
+ isAuto: Boolean(Number(row.is_auto ?? 0)),
1713
2208
  }))
1714
2209
  }
1715
2210
 
1716
2211
  // ── Sent-mail attribution (owner + automated flag + thread link) ─
1717
2212
  export type SentMeta = { owner: string | null; isAuto: boolean; inReplyTo: string | null }
1718
2213
 
2214
+ /** Writing back to somebody is the clearest statement that their mail is wanted. */
2215
+ export async function noteReplyTo(owner: string | null, address: string): Promise<void> {
2216
+ await noteSender(owner, senderDomainOf(address), 'replied').catch(() => {})
2217
+ }
2218
+
1719
2219
  export async function recordSentMeta(
1720
2220
  emailId: string,
1721
2221
  owner: string | null,
@@ -1931,13 +2431,29 @@ export async function revokeShare(id: string, owner: string): Promise<boolean> {
1931
2431
  return rows.length > 0
1932
2432
  }
1933
2433
 
1934
- export async function createResetToken(email: string, token: string, expires: number): Promise<void> {
2434
+ export async function createResetToken(
2435
+ email: string,
2436
+ token: string,
2437
+ expires: number,
2438
+ purpose: 'reset' | 'verify-recovery' = 'reset',
2439
+ ): Promise<void> {
1935
2440
  await ensureMailSchema()
1936
2441
  const sql = db()
1937
2442
  await sql`DELETE FROM mail_reset_tokens WHERE expires_at < ${nowIso()}`
1938
2443
  await sql`
1939
- INSERT INTO mail_reset_tokens (token, email, expires_at)
1940
- VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()})`
2444
+ INSERT INTO mail_reset_tokens (token, email, expires_at, purpose)
2445
+ VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()}, ${purpose})`
2446
+ }
2447
+
2448
+ /** Spend a non-reset token, returning the address it was issued for. */
2449
+ export async function consumeToken(token: string, purpose: 'verify-recovery'): Promise<string | null> {
2450
+ await ensureMailSchema()
2451
+ const sql = db()
2452
+ const rows = await sql`
2453
+ DELETE FROM mail_reset_tokens
2454
+ WHERE token = ${token} AND purpose = ${purpose} AND expires_at > ${nowIso()}
2455
+ RETURNING email`
2456
+ return rows[0]?.email ? String(rows[0].email) : null
1941
2457
  }
1942
2458
 
1943
2459
  /**
@@ -1948,7 +2464,8 @@ export async function resetTokenEmail(token: string): Promise<string | null> {
1948
2464
  await ensureMailSchema()
1949
2465
  const sql = db()
1950
2466
  const rows = await sql`
1951
- SELECT email FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()}`
2467
+ SELECT email FROM mail_reset_tokens
2468
+ WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}`
1952
2469
  const email = rows[0]?.email
1953
2470
  return email ? String(email) : null
1954
2471
  }
@@ -1958,12 +2475,17 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
1958
2475
  await ensureMailSchema()
1959
2476
  const sql = db()
1960
2477
  const consumed = await sql`
1961
- DELETE FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()} RETURNING email`
2478
+ DELETE FROM mail_reset_tokens
2479
+ WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}
2480
+ RETURNING email`
1962
2481
  const email = consumed[0]?.email
1963
2482
  if (!email) return null
1964
2483
  await sql`
1965
2484
  INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
1966
- ON CONFLICT (email) DO UPDATE SET password_hash = excluded.password_hash, status = 'active'`
2485
+ ON CONFLICT (email) DO UPDATE SET
2486
+ password_hash = excluded.password_hash,
2487
+ status = 'active',
2488
+ password_is_default = 0`
1967
2489
  // Any other link still outstanding for this address is now stale — and one of them
1968
2490
  // may be the reason the password is being changed.
1969
2491
  await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
@@ -2117,7 +2639,8 @@ export async function backfillAttachments(limit: number, countRemaining = false)
2117
2639
  rows.map(async row => {
2118
2640
  const id = String(row.id)
2119
2641
  try {
2120
- const listing = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
2642
+ const base = (process.env.RESEND_BASE_URL ?? '').trim().replace(/\/+$/, '') || 'https://api.resend.com'
2643
+ const listing = await fetch(`${base}/emails/receiving/${encodeURIComponent(id)}/attachments`, {
2121
2644
  headers: { authorization: `Bearer ${apiKey}` },
2122
2645
  })
2123
2646
  if (!listing.ok) {
@@ -2140,10 +2663,14 @@ export async function backfillAttachments(limit: number, countRemaining = false)
2140
2663
  const contentType = entry.content_type ? String(entry.content_type) : undefined
2141
2664
  const source = entry.download_url ? String(entry.download_url) : ''
2142
2665
  const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0) }
2143
- if (!source) return meta
2144
- const binary = await fetch(source)
2145
- if (!binary.ok) return meta
2146
- const bytes = Buffer.from(await binary.arrayBuffer())
2666
+ if (!source && !entry.content) return meta
2667
+ let bytes: Buffer
2668
+ if (entry.content) bytes = Buffer.from(String(entry.content), 'base64')
2669
+ else {
2670
+ const binary = await fetch(source)
2671
+ if (!binary.ok) return meta
2672
+ bytes = Buffer.from(await binary.arrayBuffer())
2673
+ }
2147
2674
  const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
2148
2675
  const key = `attachments/${id}/${index}-${safeName}`
2149
2676
  if (!(await putObject(key, bytes, contentType))) return meta