@novacraft-engineering/mailbox 0.4.5 → 0.4.18

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.
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,23 @@ 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(() => {})
345
400
  // Where a reset link goes when the account's own mailbox is the thing locked.
346
401
  await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_email TEXT').catch(() => {})
347
402
  await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_verified INTEGER NOT NULL DEFAULT 0').catch(() => {})
@@ -402,12 +457,255 @@ export function ensureMailSchema(): Promise<void> {
402
457
  }
403
458
 
404
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
+
405
697
  function mapInbound(row: Record<string, unknown>): InboundEmail {
406
698
  return {
407
699
  id: String(row.id),
408
700
  from: (row.from_addr as string) ?? '',
409
701
  to: parseArray(row.to_addrs),
410
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),
411
709
  bcc: parseArray(row.bcc),
412
710
  replyTo: parseArray(row.reply_to),
413
711
  subject: (row.subject as string) ?? '',
@@ -449,13 +747,15 @@ export type InboxPage = {
449
747
  export async function searchInbox(options: {
450
748
  text?: string
451
749
  owner?: string
452
- folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
750
+ folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
453
751
  unread?: boolean
454
752
  starred?: boolean
455
753
  hasAttachment?: boolean
456
754
  label?: string
457
755
  from?: string
458
756
  to?: string
757
+ /** direct | copied | other, or 'not-copied' to leave copies out. */
758
+ addressed?: string
459
759
  limit?: number
460
760
  offset?: number
461
761
  cursor?: string | null
@@ -490,16 +790,23 @@ export async function searchInbox(options: {
490
790
  if (options.label) { where.push('m.labels LIKE ?'); args.push(`%${options.label}%`) }
491
791
  if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
492
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) }
493
797
 
494
798
  // A snoozed message is only out of the inbox while its time is still ahead; the clause
495
799
  // does the waking, so nothing has to run on a timer.
496
800
  const nowIso = new Date().toISOString()
497
801
  const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
498
- if (options.folder === 'trash') where.push('m.trashed = 1')
499
- else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0')
500
- else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0')
501
- else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0'); args.push(nowIso) }
502
- 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) }
503
810
 
504
811
  const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
505
812
 
@@ -529,7 +836,8 @@ export async function searchInbox(options: {
529
836
  'contentType', json_extract(value, '$.contentType'),
530
837
  'size', json_extract(value, '$.size')))
531
838
  FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
532
- 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
533
841
  FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
534
842
  ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
535
843
  cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
@@ -575,6 +883,7 @@ export type FolderTally = {
575
883
  starred: number
576
884
  archived: number
577
885
  trashed: number
886
+ spam: number
578
887
  snoozed: number
579
888
  }
580
889
 
@@ -596,9 +905,13 @@ const COUNTS_CACHE_MS = 5 * 60 * 1000
596
905
 
597
906
  /** countFolders through a durable cache: one row read when fresh, a full scan only when stale. */
598
907
  export async function invalidateCounts(owner: string | null | undefined): Promise<void> {
599
- if (!owner) return
600
908
  try {
601
- 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'`
602
915
  } catch {
603
916
  }
604
917
  }
@@ -635,12 +948,13 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
635
948
  const rows = await tagged(
636
949
  sql,
637
950
  `SELECT
638
- SUM(CASE WHEN archived = 0 AND trashed = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
639
- 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,
640
- SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
641
- 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,
642
955
  SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
643
- 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
644
958
  FROM mail_inbox ${scope}`,
645
959
  [nowIso, nowIso, nowIso, ...args],
646
960
  )
@@ -661,6 +975,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
661
975
  SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
662
976
  SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
663
977
  SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
978
+ 0 AS spam,
664
979
  SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
665
980
  FROM mail_threads WHERE owner = ?`,
666
981
  [nowIso, nowIso, nowIso, owner.toLowerCase()],
@@ -673,6 +988,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
673
988
  starred: threadValue('starred'),
674
989
  archived: threadValue('archived'),
675
990
  trashed: threadValue('trashed'),
991
+ spam: threadValue('spam'),
676
992
  snoozed: threadValue('snoozed'),
677
993
  }
678
994
  }
@@ -683,6 +999,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
683
999
  starred: value('starred'),
684
1000
  archived: value('archived'),
685
1001
  trashed: value('trashed'),
1002
+ spam: value('spam'),
686
1003
  snoozed: value('snoozed'),
687
1004
  conversations,
688
1005
  }
@@ -694,10 +1011,10 @@ export async function readInbox(filter?: { owner?: string }): Promise<InboundEma
694
1011
  const owner = filter?.owner?.trim().toLowerCase()
695
1012
  const rows = owner
696
1013
  ? await sql`
697
- 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
698
1015
  FROM mail_inbox WHERE lower(owner) = ${owner} ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
699
1016
  : await sql`
700
- 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
701
1018
  FROM mail_inbox ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
702
1019
  return rows.map(mapInbound)
703
1020
  }
@@ -715,6 +1032,7 @@ export type ThreadRow = {
715
1032
  inboxCount: number
716
1033
  archivedCount: number
717
1034
  trashedCount: number
1035
+ spamCount: number
718
1036
  attachCount: number
719
1037
  senders: string[]
720
1038
  snippet: string
@@ -753,12 +1071,14 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
753
1071
  const owner = ownerRaw.toLowerCase()
754
1072
  const agg = await sql`
755
1073
  SELECT COUNT(*) AS n,
756
- SUM(CASE WHEN read = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS unread,
757
- SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
758
- SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
759
- 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,
760
1079
  SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
761
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,
762
1082
  MIN(received_at) AS first_at, MAX(received_at) AS latest_at
763
1083
  FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
764
1084
  const total = Number(agg[0]?.n ?? 0)
@@ -767,7 +1087,7 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
767
1087
  return
768
1088
  }
769
1089
  const latest = await sql`
770
- 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
771
1091
  FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
772
1092
  ORDER BY received_at DESC, id DESC LIMIT 1`
773
1093
  const members = await sql`
@@ -783,19 +1103,22 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
783
1103
  const head = latest[0]
784
1104
  await sql`
785
1105
  INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
786
- 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, risk)
787
1107
  VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
788
1108
  ${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
789
1109
  ${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
790
- ${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].attach ?? 0)},
791
- ${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'})
792
1114
  ON CONFLICT (owner, thread_id) DO UPDATE SET
793
1115
  subject_key = excluded.subject_key, subject = excluded.subject, first_at = excluded.first_at,
794
1116
  latest_at = excluded.latest_at, latest_id = excluded.latest_id, count = excluded.count,
795
1117
  unread_count = excluded.unread_count, starred_count = excluded.starred_count,
796
1118
  inbox_count = excluded.inbox_count, archived_count = excluded.archived_count,
797
- trashed_count = excluded.trashed_count, attach_count = excluded.attach_count,
798
- 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`
799
1122
  }
800
1123
 
801
1124
  /** Thread the message and refresh its summary; never lets a threading fault fail a write. */
@@ -836,7 +1159,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
836
1159
  }
837
1160
  }
838
1161
 
839
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
1162
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
840
1163
 
841
1164
  /** The newest conversations in a folder: one row each, already summarised. */
842
1165
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
@@ -846,7 +1169,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
846
1169
  const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
847
1170
  const nowIso = new Date().toISOString()
848
1171
  const predicate =
849
- folder === 'archive' ? 'archived_count > 0'
1172
+ folder === 'spam' ? 'spam_count > 0'
1173
+ : folder === 'archive' ? 'archived_count > 0'
850
1174
  : folder === 'trash' ? 'trashed_count > 0'
851
1175
  : folder === 'starred' ? 'starred_count > 0'
852
1176
  : folder === 'snoozed' ? 'snoozed_until > ?'
@@ -866,8 +1190,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
866
1190
  SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
867
1191
  SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
868
1192
  SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
869
- SUM(trashed_count) AS trashed_count, SUM(attach_count) AS attach_count,
870
- 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
871
1195
  FROM mail_threads
872
1196
  GROUP BY thread_id
873
1197
  HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
@@ -875,7 +1199,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
875
1199
  [...folderArgs, ...cursorArgs, limit])
876
1200
  : await tagged(db(), `
877
1201
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
878
- 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
879
1203
  FROM mail_threads WHERE owner = ? AND ${predicate}
880
1204
  ${cursorClause ? `AND ${cursorClause}` : ''}
881
1205
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
@@ -894,11 +1218,14 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
894
1218
  inboxCount: Number(row.inbox_count ?? 0),
895
1219
  archivedCount: Number(row.archived_count ?? 0),
896
1220
  trashedCount: Number(row.trashed_count ?? 0),
1221
+ spamCount: Number(row.spam_count ?? 0),
897
1222
  attachCount: Number(row.attach_count ?? 0),
898
1223
  senders: parseJson<string[]>(row.senders, []),
899
1224
  snippet: String(row.snippet ?? ''),
900
1225
  labels: parseJson<string[]>(row.labels, []),
901
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,
902
1229
  }))
903
1230
  return { rows: mapped, nextCursor }
904
1231
  }
@@ -968,13 +1295,112 @@ export async function appendInbound(
968
1295
  await ensureMailSchema()
969
1296
  const sql = db()
970
1297
  await sql`
971
- 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)
972
- 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})
973
1300
  ON CONFLICT (id) DO NOTHING`
974
1301
  await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
975
1302
  await invalidateCounts(email.owner)
976
1303
  }
977
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) {
1365
+ // The verdict is unchanged, but the conversation summary the list reads from may still
1366
+ // predate it — worth one refresh for the few that carry a warning.
1367
+ if (verdict.risk !== 'clean') await rethreadAfterChange(String(row.id))
1368
+ continue
1369
+ }
1370
+
1371
+ await sql`
1372
+ UPDATE mail_inbox
1373
+ SET risk = ${verdict.risk}, risk_reasons = ${JSON.stringify(verdict.reasons)}, spam = ${quarantine ? 1 : 0}
1374
+ WHERE id = ${String(row.id)}`
1375
+ await rethreadAfterChange(String(row.id))
1376
+ result.changed += 1
1377
+ if (quarantine) result.quarantined += 1
1378
+ if (owner) touchedOwners.add(owner)
1379
+ }
1380
+
1381
+ for (const owner of touchedOwners) await invalidateCounts(owner)
1382
+ return result
1383
+ }
1384
+
1385
+ export async function repairInbound(
1386
+ email: Pick<InboundEmail, 'id' | 'html' | 'text' | 'headers' | 'attachments'>,
1387
+ ): Promise<boolean> {
1388
+ await ensureMailSchema()
1389
+ const sql = db()
1390
+ const rows = await sql`
1391
+ UPDATE mail_inbox SET
1392
+ html = CASE WHEN coalesce(html, '') = '' THEN ${email.html} ELSE html END,
1393
+ body_text = CASE WHEN coalesce(body_text, '') = '' THEN ${email.text} ELSE body_text END,
1394
+ headers = CASE WHEN coalesce(headers, '') IN ('', '{}') THEN ${JSON.stringify(email.headers)} ELSE headers END,
1395
+ attachments = CASE WHEN coalesce(attachments, '') IN ('', '[]') THEN ${JSON.stringify(email.attachments)} ELSE attachments END,
1396
+ attach_meta = CASE WHEN coalesce(attach_meta, '') IN ('', '[]') THEN ${attachMeta(email.attachments)} ELSE attach_meta END,
1397
+ snippet = CASE WHEN coalesce(snippet, '') = '' THEN ${listSnippet(email.text)} ELSE snippet END,
1398
+ thread_meta = CASE WHEN coalesce(thread_meta, '') IN ('', '{}') THEN ${threadMeta(email.headers)} ELSE thread_meta END
1399
+ WHERE id = ${email.id}
1400
+ RETURNING id`
1401
+ return rows.length > 0
1402
+ }
1403
+
978
1404
  export async function getInboundSource(
979
1405
  id: string,
980
1406
  ): Promise<{ owner: string | null; attachments: Array<Record<string, unknown>> } | null> {
@@ -1222,13 +1648,44 @@ export async function listInboundWithAttachments(owner: string | null): Promise<
1222
1648
  export async function setInboxOwner(id: string, owner: string | null): Promise<void> {
1223
1649
  const sql = db()
1224
1650
  const before = await sql`SELECT owner, thread_id FROM mail_inbox WHERE id = ${id}`
1225
- await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL WHERE id = ${id}`
1651
+ const moved = await sql`SELECT to_addrs, cc FROM mail_inbox WHERE id = ${id}`
1652
+ const nowAddressed = classifyAddressed(owner, parseArray(moved[0]?.to_addrs), parseArray(moved[0]?.cc))
1653
+ // A message that was a copy in one mailbox can be direct mail in another.
1654
+ await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL, addressed = ${nowAddressed} WHERE id = ${id}`
1655
+ // Both sides change: the mailbox it left and the one it arrived in.
1656
+ await invalidateCounts(before[0]?.owner == null ? null : String(before[0].owner))
1657
+ await invalidateCounts(owner)
1226
1658
  await rethreadAfterChange(id, before[0]?.owner == null ? null : String(before[0].owner), before[0]?.thread_id == null ? null : String(before[0].thread_id))
1227
1659
  }
1228
1660
 
1229
1661
  export async function markInboundRead(id: string): Promise<void> {
1230
1662
  const sql = db()
1231
1663
  await sql`UPDATE mail_inbox SET read = 1 WHERE id = ${id}`
1664
+ const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1665
+ await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1666
+ await rethreadAfterChange(id)
1667
+ }
1668
+
1669
+ /**
1670
+ * Moves a message in or out of quarantine and remembers the decision. This is the loop:
1671
+ * what the reader does with a sender's mail decides how the next one is treated, so the
1672
+ * same message can be spam in one mailbox and ordinary correspondence in another.
1673
+ */
1674
+ export async function setInboundSpam(id: string, spam: boolean): Promise<void> {
1675
+ await ensureMailSchema()
1676
+ const sql = db()
1677
+ const rows = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
1678
+ const row = rows[0]
1679
+ // A reader who says "not spam" has overruled the judgement, so the warning goes with the
1680
+ // quarantine — leaving the badge on would argue with them every time they open it.
1681
+ await (spam
1682
+ ? sql`UPDATE mail_inbox SET spam = 1, archived = 0, trashed = 0 WHERE id = ${id}`
1683
+ : sql`UPDATE mail_inbox SET spam = 0, archived = 0, trashed = 0, risk = 'clean', risk_reasons = '[]' WHERE id = ${id}`)
1684
+ if (row?.owner) {
1685
+ await noteSender(String(row.owner), senderDomainOf(String(row.from_addr ?? '')), spam ? 'marked_spam' : 'replied')
1686
+ .catch(() => {})
1687
+ }
1688
+ await invalidateCounts(row?.owner == null ? null : String(row.owner))
1232
1689
  await rethreadAfterChange(id)
1233
1690
  }
1234
1691
 
@@ -1238,7 +1695,12 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
1238
1695
  if (flags.starred !== undefined) await sql`UPDATE mail_inbox SET starred = ${flags.starred} WHERE id = ${id}`
1239
1696
  if (flags.archived !== undefined) await sql`UPDATE mail_inbox SET archived = ${flags.archived} WHERE id = ${id}`
1240
1697
  if (flags.trashed !== undefined) await sql`UPDATE mail_inbox SET trashed = ${flags.trashed} WHERE id = ${id}`
1241
- const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1698
+ const owned = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
1699
+ // Binning a sender's mail counts against them; it is the commonest way a reader says
1700
+ // "not this one" without ever pressing a button marked spam.
1701
+ if (flags.trashed === true && owned[0]?.owner) {
1702
+ await noteSender(String(owned[0].owner), senderDomainOf(String(owned[0].from_addr ?? '')), 'trashed').catch(() => {})
1703
+ }
1242
1704
  await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1243
1705
  await rethreadAfterChange(id)
1244
1706
  }
@@ -1304,6 +1766,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
1304
1766
  export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
1305
1767
  const sql = db()
1306
1768
  await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
1769
+ const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
1770
+ await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
1307
1771
  }
1308
1772
 
1309
1773
  // ── Sent-mail flags (star / archive / trash on Resend-sent emails) ──
@@ -1752,6 +2216,11 @@ export async function readSentArchive(options: {
1752
2216
  // ── Sent-mail attribution (owner + automated flag + thread link) ─
1753
2217
  export type SentMeta = { owner: string | null; isAuto: boolean; inReplyTo: string | null }
1754
2218
 
2219
+ /** Writing back to somebody is the clearest statement that their mail is wanted. */
2220
+ export async function noteReplyTo(owner: string | null, address: string): Promise<void> {
2221
+ await noteSender(owner, senderDomainOf(address), 'replied').catch(() => {})
2222
+ }
2223
+
1755
2224
  export async function recordSentMeta(
1756
2225
  emailId: string,
1757
2226
  owner: string | null,
@@ -2018,7 +2487,10 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
2018
2487
  if (!email) return null
2019
2488
  await sql`
2020
2489
  INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
2021
- ON CONFLICT (email) DO UPDATE SET password_hash = excluded.password_hash, status = 'active'`
2490
+ ON CONFLICT (email) DO UPDATE SET
2491
+ password_hash = excluded.password_hash,
2492
+ status = 'active',
2493
+ password_is_default = 0`
2022
2494
  // Any other link still outstanding for this address is now stale — and one of them
2023
2495
  // may be the reason the password is being changed.
2024
2496
  await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
@@ -2172,7 +2644,8 @@ export async function backfillAttachments(limit: number, countRemaining = false)
2172
2644
  rows.map(async row => {
2173
2645
  const id = String(row.id)
2174
2646
  try {
2175
- const listing = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
2647
+ const base = (process.env.RESEND_BASE_URL ?? '').trim().replace(/\/+$/, '') || 'https://api.resend.com'
2648
+ const listing = await fetch(`${base}/emails/receiving/${encodeURIComponent(id)}/attachments`, {
2176
2649
  headers: { authorization: `Bearer ${apiKey}` },
2177
2650
  })
2178
2651
  if (!listing.ok) {
@@ -2195,10 +2668,14 @@ export async function backfillAttachments(limit: number, countRemaining = false)
2195
2668
  const contentType = entry.content_type ? String(entry.content_type) : undefined
2196
2669
  const source = entry.download_url ? String(entry.download_url) : ''
2197
2670
  const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0) }
2198
- if (!source) return meta
2199
- const binary = await fetch(source)
2200
- if (!binary.ok) return meta
2201
- const bytes = Buffer.from(await binary.arrayBuffer())
2671
+ if (!source && !entry.content) return meta
2672
+ let bytes: Buffer
2673
+ if (entry.content) bytes = Buffer.from(String(entry.content), 'base64')
2674
+ else {
2675
+ const binary = await fetch(source)
2676
+ if (!binary.ok) return meta
2677
+ bytes = Buffer.from(await binary.arrayBuffer())
2678
+ }
2202
2679
  const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
2203
2680
  const key = `attachments/${id}/${index}-${safeName}`
2204
2681
  if (!(await putObject(key, bytes, contentType))) return meta