@novacraft-engineering/mailbox 0.4.28 → 0.4.30

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.
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+ import { DEFAULT_INBOX_FILTERS, inboxFiltersSql, matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from './inbox-filters.ts'
4
+
5
+ assert.deepEqual(normalizeInboxFilters(undefined), DEFAULT_INBOX_FILTERS, 'untouched settings get the defaults')
6
+ assert.deepEqual(normalizeInboxFilters([]), [], 'an emptied list stays empty')
7
+ assert.deepEqual(
8
+ normalizeInboxFilters([
9
+ { kind: 'senderDomain', value: ' @Example.COM ' },
10
+ { kind: 'senderDomain', value: 'example.com' },
11
+ { kind: 'bogus', value: 'x' },
12
+ { kind: 'subjectPrefix', value: ' ' },
13
+ ]),
14
+ [{ kind: 'senderDomain', value: 'example.com' }],
15
+ 'sender values are lowercased, duplicates and junk dropped',
16
+ )
17
+
18
+ const threads = [
19
+ { id: 'dmarc', senders: ['DMARC <dmarcreport@microsoft.com>'], subject: '[Preview] Report Domain: example.com', snippet: 'This is a DMARC report' },
20
+ { id: 'dmarc-joined', senders: ['dmarcreport@microsoft.com', 'Ada <ada@example.org>'], subject: 'Re: report', snippet: 'Thanks' },
21
+ { id: 'newsletter', senders: ['news@mail.example.net'], subject: 'Weekly: what shipped', snippet: '\n Unsubscribe any time' },
22
+ { id: 'person', senders: ['Ada <ada@example.org>'], subject: 'Lunch 50% off?', snippet: 'Hi' },
23
+ ]
24
+
25
+ const db = new DatabaseSync(':memory:')
26
+ db.exec('CREATE TABLE mail_threads (thread_id TEXT, senders TEXT, subject TEXT, snippet TEXT)')
27
+ const insert = db.prepare('INSERT INTO mail_threads VALUES (?, ?, ?, ?)')
28
+ for (const thread of threads) insert.run(thread.id, JSON.stringify(thread.senders), thread.subject, thread.snippet)
29
+
30
+ const viaSql = (filters: InboxFilter[]) => {
31
+ const clause = inboxFiltersSql(filters)
32
+ if (!clause) return []
33
+ return db.prepare(`SELECT thread_id FROM mail_threads t WHERE ${clause.sql} ORDER BY thread_id`).all(...clause.args).map(row => String(row.thread_id))
34
+ }
35
+ const viaJs = (filters: InboxFilter[]) =>
36
+ threads.filter(thread => matchesInboxFilters(filters, { senders: thread.senders, subject: thread.subject, text: thread.snippet })).map(thread => thread.id).sort()
37
+
38
+ const cases: Array<[InboxFilter[], string[]]> = [
39
+ [DEFAULT_INBOX_FILTERS, ['dmarc']],
40
+ [[{ kind: 'senderDomain', value: 'example.net' }], ['newsletter']],
41
+ [[{ kind: 'senderDomain', value: 'microsoft.com' }], ['dmarc']],
42
+ [[{ kind: 'subjectPrefix', value: 'weekly:' }], ['newsletter']],
43
+ [[{ kind: 'subjectContains', value: '50%' }], ['person']],
44
+ [[{ kind: 'subjectContains', value: '0%' }], ['person']],
45
+ [[{ kind: 'messagePrefix', value: 'unsubscribe' }], ['newsletter']],
46
+ [[{ kind: 'senderPrefix', value: 'dmarcreport' }, { kind: 'senderPrefix', value: 'ada' }], ['dmarc', 'dmarc-joined', 'person']],
47
+ ]
48
+ for (const [filters, expected] of cases) {
49
+ assert.deepEqual(viaSql(filters), expected, `sql ${JSON.stringify(filters)}`)
50
+ assert.deepEqual(viaJs(filters), expected, `js ${JSON.stringify(filters)}`)
51
+ }
52
+ assert.equal(inboxFiltersSql([]), null)
53
+
54
+ console.log('inbox-filters: ok')
@@ -0,0 +1,111 @@
1
+ export type InboxFilterKind = 'senderPrefix' | 'senderDomain' | 'subjectPrefix' | 'subjectContains' | 'messagePrefix'
2
+ export type InboxFilter = { kind: InboxFilterKind; value: string }
3
+
4
+ export const INBOX_FILTER_KINDS: Array<{ kind: InboxFilterKind; label: string; placeholder: string }> = [
5
+ { kind: 'senderPrefix', label: 'Sender starts with', placeholder: 'dmarcreport' },
6
+ { kind: 'senderDomain', label: 'Sender domain', placeholder: 'example.com' },
7
+ { kind: 'subjectPrefix', label: 'Subject starts with', placeholder: 'Report Domain:' },
8
+ { kind: 'subjectContains', label: 'Subject contains', placeholder: 'newsletter' },
9
+ { kind: 'messagePrefix', label: 'Message starts with', placeholder: 'This is an automated' },
10
+ ]
11
+
12
+ export const DEFAULT_INBOX_FILTERS: InboxFilter[] = [{ kind: 'senderPrefix', value: 'dmarcreport' }]
13
+
14
+ const MAX_FILTERS = 40
15
+ const MAX_VALUE = 200
16
+ const SENDER_KINDS = new Set<InboxFilterKind>(['senderPrefix', 'senderDomain'])
17
+
18
+ /** A person who never touched the list gets the defaults; one who emptied it gets none. */
19
+ export function normalizeInboxFilters(raw: unknown): InboxFilter[] {
20
+ if (!Array.isArray(raw)) return DEFAULT_INBOX_FILTERS
21
+ const seen = new Set<string>()
22
+ const filters: InboxFilter[] = []
23
+ for (const entry of raw) {
24
+ const kind = (entry as InboxFilter)?.kind
25
+ if (!INBOX_FILTER_KINDS.some(option => option.kind === kind)) continue
26
+ let value = String((entry as InboxFilter).value ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE)
27
+ if (SENDER_KINDS.has(kind)) value = value.toLowerCase().replace(/^@/, '')
28
+ const key = `${kind}:${value.toLowerCase()}`
29
+ if (!value || seen.has(key)) continue
30
+ seen.add(key)
31
+ filters.push({ kind, value })
32
+ if (filters.length === MAX_FILTERS) break
33
+ }
34
+ return filters
35
+ }
36
+
37
+ const likeEscape = (value: string) => value.replace(/[\\%_]/g, match => `\\${match}`)
38
+
39
+ function senderAddress(raw: string): string {
40
+ const angled = raw.match(/<([^>]*)>?/)
41
+ return (angled ? angled[1] : raw).trim().toLowerCase()
42
+ }
43
+
44
+ const ADDRESS_SQL = "lower(trim(rtrim(CASE WHEN instr(value, '<') > 0 THEN substr(value, instr(value, '<') + 1) ELSE value END, '> ')))"
45
+ const SNIPPET_SQL = "ltrim(coalesce(t.snippet, ''), ' ' || char(9, 10, 13))"
46
+
47
+ /**
48
+ * The rules as one SQL condition over a mail_threads row aliased `t`. Sender rules hold only
49
+ * when every sender in the conversation matches, so a person replying into a filtered
50
+ * thread brings it back; subject and message rules read the latest message.
51
+ */
52
+ export function inboxFiltersSql(filters: InboxFilter[]): { sql: string; args: string[] } | null {
53
+ if (!filters.length) return null
54
+ const senderRules: string[] = []
55
+ const senderArgs: string[] = []
56
+ const rules: string[] = []
57
+ const args: string[] = []
58
+ for (const filter of filters) {
59
+ const value = likeEscape(filter.value)
60
+ if (filter.kind === 'senderPrefix') {
61
+ senderRules.push(`${ADDRESS_SQL} LIKE ? ESCAPE '\\'`)
62
+ senderArgs.push(`${value.toLowerCase()}%`)
63
+ } else if (filter.kind === 'senderDomain') {
64
+ senderRules.push(`(${ADDRESS_SQL} LIKE ? ESCAPE '\\' OR ${ADDRESS_SQL} LIKE ? ESCAPE '\\')`)
65
+ senderArgs.push(`%@${value.toLowerCase()}`, `%.${value.toLowerCase()}`)
66
+ } else if (filter.kind === 'subjectPrefix') {
67
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
68
+ args.push(`${value}%`)
69
+ } else if (filter.kind === 'subjectContains') {
70
+ rules.push(`coalesce(t.subject, '') LIKE ? ESCAPE '\\'`)
71
+ args.push(`%${value}%`)
72
+ } else {
73
+ rules.push(`${SNIPPET_SQL} LIKE ? ESCAPE '\\'`)
74
+ args.push(`${value}%`)
75
+ }
76
+ }
77
+ const parts = [...rules]
78
+ if (senderRules.length) {
79
+ parts.unshift(
80
+ `(json_array_length(coalesce(t.senders, '[]')) > 0 AND NOT EXISTS (SELECT 1 FROM json_each(coalesce(t.senders, '[]')) WHERE NOT (${senderRules.join(' OR ')})))`,
81
+ )
82
+ }
83
+ return { sql: `(${parts.join(' OR ')})`, args: [...senderArgs, ...args] }
84
+ }
85
+
86
+ /** The same rules for one message, for the paths that never reach SQL — a push, say. */
87
+ export function matchesInboxFilters(
88
+ filters: InboxFilter[],
89
+ message: { senders: string[]; subject: string | null | undefined; text: string | null | undefined },
90
+ ): boolean {
91
+ const addresses = message.senders.filter(Boolean).map(senderAddress)
92
+ const subject = (message.subject ?? '').toLowerCase()
93
+ const text = (message.text ?? '').trimStart().toLowerCase()
94
+ const senderMatches = (address: string) =>
95
+ filters.some(filter => {
96
+ if (filter.kind === 'senderPrefix') return address.startsWith(filter.value.toLowerCase())
97
+ if (filter.kind === 'senderDomain') {
98
+ const domain = filter.value.toLowerCase()
99
+ return address.endsWith(`@${domain}`) || address.endsWith(`.${domain}`)
100
+ }
101
+ return false
102
+ })
103
+ if (filters.some(filter => SENDER_KINDS.has(filter.kind)) && addresses.length && addresses.every(senderMatches)) return true
104
+ return filters.some(filter => {
105
+ const value = filter.value.toLowerCase()
106
+ if (filter.kind === 'subjectPrefix') return subject.startsWith(value)
107
+ if (filter.kind === 'subjectContains') return subject.includes(value)
108
+ if (filter.kind === 'messagePrefix') return text.startsWith(value)
109
+ return false
110
+ })
111
+ }
package/lib/mailbox.ts CHANGED
@@ -13,6 +13,10 @@ import { hashPassword } from './password'
13
13
  import { duration, type ParsedQuery } from '@/app/mail/search'
14
14
  import { turso, tursoBatch, tursoQuery } from './turso'
15
15
  import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
16
+ import { inboxFiltersSql, normalizeInboxFilters, type InboxFilter } from './inbox-filters'
17
+ import { judgeMessage, senderDomainOf, type Risk, type RiskJudgement, type RiskSignals, type SenderStanding } from './risk'
18
+
19
+ export { judgeMessage, senderDomainOf, type Risk, type RiskJudgement, type RiskSignals, type SenderStanding }
16
20
 
17
21
  export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
18
22
 
@@ -386,6 +390,7 @@ export function ensureMailSchema(): Promise<void> {
386
390
  await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN spam INTEGER NOT NULL DEFAULT 0").catch(() => {})
387
391
  await sqlRaw("CREATE INDEX IF NOT EXISTS mail_inbox_spam_idx ON mail_inbox (lower(owner), spam, received_at DESC)").catch(() => {})
388
392
  await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN risk_reasons TEXT").catch(() => {})
393
+ await sqlRaw('ALTER TABLE mail_sender_reputation ADD COLUMN trusted INTEGER NOT NULL DEFAULT 0').catch(() => {})
389
394
  // The row in the list follows its newest message, so the conversation carries it too.
390
395
  await sqlRaw("ALTER TABLE mail_threads ADD COLUMN addressed TEXT").catch(() => {})
391
396
  // Worst verdict in the conversation, so a warning cannot hide behind a later reply.
@@ -454,30 +459,47 @@ export function ensureMailSchema(): Promise<void> {
454
459
 
455
460
  // ── Inbox ──────────────────────────────────────────────────────
456
461
 
457
- export type SenderStanding = {
458
- received: number
459
- trashed: number
460
- markedSpam: number
461
- replied: number
462
- firstSeen: string | null
462
+
463
+ const domainMatch = (domain: string) => {
464
+ const host = domain.toLowerCase()
465
+ return {
466
+ sql: "(lower(from_addr) LIKE ? OR lower(from_addr) LIKE ? OR lower(from_addr) LIKE ? OR lower(from_addr) LIKE ?)",
467
+ args: [`%@${host}`, `%@${host}>`, `%.${host}`, `%.${host}>`],
468
+ }
469
+ }
470
+
471
+ // The counters began long after the history they sit beside, so years of imported mail would
472
+ // read as a stranger's first message. The stored mail itself says when a domain first wrote.
473
+ async function earliestFrom(domain: string): Promise<string | null> {
474
+ const match = domainMatch(domain)
475
+ const rows = await tagged(db(), `SELECT MIN(received_at) AS first FROM mail_inbox WHERE ${match.sql}`, match.args)
476
+ return rows[0]?.first == null ? null : String(rows[0].first)
463
477
  }
464
478
 
465
479
  /** What this mailbox has done with this sender's domain before. */
466
- export async function senderStanding(owner: string | null, domain: string): Promise<SenderStanding> {
467
- const empty = { received: 0, trashed: 0, markedSpam: 0, replied: 0, firstSeen: null }
480
+ export async function senderStanding(
481
+ owner: string | null,
482
+ domain: string,
483
+ earliestCache?: Map<string, string | null>,
484
+ ): Promise<SenderStanding> {
485
+ const empty = { received: 0, trashed: 0, markedSpam: 0, replied: 0, trusted: false, firstSeen: null }
468
486
  if (!owner || !domain) return empty
469
487
  await ensureMailSchema()
470
488
  const rows = await db()`
471
- SELECT received, trashed, marked_spam, replied, first_seen FROM mail_sender_reputation
489
+ SELECT received, trashed, marked_spam, replied, trusted, first_seen FROM mail_sender_reputation
472
490
  WHERE owner = ${owner.toLowerCase()} AND domain = ${domain.toLowerCase()}`
473
491
  const row = rows[0]
474
- if (!row) return empty
492
+ const key = domain.toLowerCase()
493
+ const earliest = earliestCache?.has(key) ? earliestCache.get(key)! : await earliestFrom(key)
494
+ earliestCache?.set(key, earliest)
495
+ const counted = row?.first_seen == null ? null : String(row.first_seen)
475
496
  return {
476
- received: Number(row.received ?? 0),
477
- trashed: Number(row.trashed ?? 0),
478
- markedSpam: Number(row.marked_spam ?? 0),
479
- replied: Number(row.replied ?? 0),
480
- firstSeen: row.first_seen == null ? null : String(row.first_seen),
497
+ received: Number(row?.received ?? 0),
498
+ trashed: Number(row?.trashed ?? 0),
499
+ markedSpam: Number(row?.marked_spam ?? 0),
500
+ replied: Number(row?.replied ?? 0),
501
+ trusted: Boolean(Number(row?.trusted ?? 0)),
502
+ firstSeen: [counted, earliest].filter((value): value is string => Boolean(value)).sort()[0] ?? null,
481
503
  }
482
504
  }
483
505
 
@@ -504,189 +526,6 @@ export async function noteSender(
504
526
  last_seen = ${now}`
505
527
  }
506
528
 
507
- /** What the scanners and the sender's own domain said. */
508
- export type Risk = 'clean' | 'suspicious' | 'spam' | 'virus'
509
-
510
- export type RiskSignals = {
511
- spam?: string | null
512
- virus?: string | null
513
- spf?: string | null
514
- dkim?: string | null
515
- dmarc?: string | null
516
- /** The message itself, for the tells authentication cannot see. */
517
- from?: string | null
518
- replyTo?: string[] | null
519
- subject?: string | null
520
- text?: string | null
521
- }
522
-
523
- /** Defaults only. Each is overridable per deployment, so a list can change without a
524
- * release — metroperil can drop a word its own trade uses every day. */
525
- const FREE_MAIL_DEFAULT = new Set([
526
- 'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'hotmail.com', 'outlook.com',
527
- 'live.com', 'aol.com', 'protonmail.com', 'proton.me', 'mail.com', 'gmx.com', 'yandex.com',
528
- 'icloud.com', 'zoho.com', 'inbox.lv', 'consultant.com', 'qq.com', '163.com',
529
- ])
530
-
531
- const THROWAWAY_TLDS_DEFAULT = new Set([
532
- 'xyz', 'top', 'buzz', 'click', 'link', 'work', 'gq', 'cf', 'ml', 'tk', 'ga',
533
- 'loan', 'men', 'date', 'racing', 'win', 'stream', 'download', 'review', 'country', 'kim',
534
- ])
535
-
536
- /** The shape of an advance-fee approach. Counted, never single-word: one alone is innocent. */
537
- // Only wording that is odd in ordinary business correspondence belongs here. A single
538
- // generic term is not evidence of anything: an insurance broker writes "beneficiary" and
539
- // "bank draft" all day, a logistics firm writes "consignment", and every sales team sends
540
- // a "business proposal". Add them per deployment through MAIL_SCAM_PHRASES if a mailbox
541
- // genuinely never sees them.
542
- const SCAM_PHRASES_DEFAULT = [
543
- 'next of kin', 'sole beneficiary', 'late client', 'deceased client',
544
- 'inheritance', 'died without', 'without a will', 'unclaimed inheritance',
545
- 'winning notification', 'lottery winner', 'western union', 'atm card',
546
- 'transfer to your account immediately', 'strictly confidential and urgent',
547
- ]
548
-
549
- /** The registrable domain behind an address, for reputation to be keyed on. */
550
- export const senderDomainOf = (address: string): string => registrable(domainOf(address))
551
-
552
- const domainOf = (address: string): string => {
553
- const angled = address.match(/<([^>]+)>/)
554
- const bare = (angled ? angled[1] : address).trim().toLowerCase()
555
- return bare.split('@').pop() ?? ''
556
- }
557
-
558
- /** example.co.uk and example.com both reduce to the name somebody actually registered. */
559
- const registrable = (host: string): string => {
560
- const parts = host.split('.').filter(Boolean)
561
- if (parts.length <= 2) return parts.join('.')
562
- const twoLevel = /^(co|com|org|net|gov|ac|edu|ltd|plc)\.[a-z]{2}$/.test(parts.slice(-2).join('.'))
563
- return parts.slice(twoLevel ? -3 : -2).join('.')
564
- }
565
-
566
- const failed = (verdict: string | null | undefined): boolean =>
567
- typeof verdict === 'string' && /^(fail|softfail|permerror)$/i.test(verdict.trim())
568
-
569
- const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<string> => {
570
- const parsed = (raw ?? '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
571
- return parsed.length ? new Set(parsed) : new Set(fallback)
572
- }
573
-
574
- // Read per call, so a deployment can change any of them without a release.
575
- const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
576
- const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
577
-
578
- // Bulk senders put their own bounce domain in From and the real correspondent in Reply-To.
579
- // That is how the campaign gets replies, not an attempt to redirect them somewhere unexpected.
580
- const BULK_SENDERS_DEFAULT = new Set([
581
- 'mailchimpapp.com', 'mcsv.net', 'rsgsv.net', 'mailchimp.com',
582
- 'sendgrid.net', 'sendgrid.com', 'sparkpostmail.com', 'amazonses.com',
583
- 'mailgun.org', 'mandrillapp.com', 'postmarkapp.com', 'sendinblue.com',
584
- 'brevo.com', 'constantcontact.com', 'cmail19.com', 'createsend.com',
585
- 'hubspotemail.net', 'mailerlite.com', 'klaviyomail.com', 'salesforce.com',
586
- ])
587
- const bulkSenders = () => listFrom(process.env.MAIL_BULK_SENDERS, BULK_SENDERS_DEFAULT)
588
- const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
589
-
590
- /** Weight at which a message stops being labelled and is held out of the inbox instead. */
591
- const quarantineAt = () => Number(process.env.MAIL_SPAM_THRESHOLD ?? 6)
592
-
593
- /** Below this nothing is said at all. One small oddity is not a case. */
594
- const flagAt = () => Number(process.env.MAIL_SUSPICION_THRESHOLD ?? 3)
595
-
596
- export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quarantine: boolean }
597
-
598
- /**
599
- * What this mailbox knows, then what is true of the message. The standing a sender has
600
- * built here leads: somebody you have written back to is not spam because their subject
601
- * shouts, and somebody whose mail you have binned repeatedly does not get the benefit of
602
- * the doubt again. The fixed rules only decide the cases with no history to go on, and
603
- * every one of their lists can be changed per deployment without a release.
604
- */
605
- export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
606
- const reasons: string[] = []
607
- let score = 0
608
- // A finding is "telling" when it is hard to trip by accident. Failing an authentication
609
- // check or shouting in the subject line is neither: ordinary mail does both. Holding a
610
- // message back takes at least one finding of the first kind, however the weights add up.
611
- let telling = 0
612
- const add = (weight: number, why: string, isTelling = false) => {
613
- score += weight
614
- if (isTelling) telling += 1
615
- reasons.push(why)
616
- }
617
-
618
- if (/^fail$/i.test((signals.virus ?? '').trim())) {
619
- return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
620
- }
621
-
622
- // Trust is earned by being written back to, never by volume alone: a sender whose mail
623
- // arrives forty times and is binned every time has not earned anything.
624
- const trusted = standing.replied > 0 && standing.markedSpam === 0
625
- if (standing.markedSpam > 0) {
626
- add(4 + Math.min(standing.markedSpam, 4),
627
- `You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`, true)
628
- } else if (standing.trashed >= 3 && standing.replied === 0) {
629
- add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`, true)
630
- }
631
-
632
- if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message', true)
633
-
634
- const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
635
- // Heavy, but not enough on its own to hide a message: mail forwarded through a list
636
- // breaks alignment and fails DMARC while being perfectly legitimate. It warns loudly;
637
- // it takes a second finding to put a message out of sight.
638
- if (failed(signals.dmarc)) add(4, 'The sending domain says this message is not from them (DMARC failed)')
639
- else if (!authenticated) {
640
- if (failed(signals.spf)) add(2, 'The sending server is not authorised by that domain (SPF failed)')
641
- if (failed(signals.dkim)) add(2, 'The signature does not match the sending domain (DKIM failed)')
642
- }
643
-
644
- const fromDomain = registrable(domainOf(signals.from ?? ''))
645
- const replyDomains = (signals.replyTo ?? [])
646
- .map(entry => registrable(domainOf(entry)))
647
- .filter(entry => entry && entry !== fromDomain)
648
- const free = freeProviders()
649
- const freeReply = replyDomains.find(entry => free.has(entry))
650
- const bulk = bulkSenders().has(fromDomain)
651
- if (bulk) {
652
- // Nothing to say: a campaign's replies are meant to land somewhere other than the
653
- // sending platform, and treating that as misdirection buries ordinary bulk mail.
654
- } else if (freeReply && fromDomain && !free.has(fromDomain)) {
655
- add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`, true)
656
- } else if (replyDomains.length) {
657
- add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
658
- }
659
-
660
- const tld = fromDomain.split('.').pop() ?? ''
661
- if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`, true)
662
- if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits', true)
663
-
664
- const subject = (signals.subject ?? '').trim()
665
- const letters = subject.replace(/[^A-Za-z]/g, '')
666
- if (letters.length >= 12 && letters === letters.toUpperCase()) add(1, 'The subject is written entirely in capitals')
667
-
668
- const body = (signals.text ?? '').toLowerCase()
669
- const hits = scamPhrases().filter(phrase => body.includes(phrase))
670
- if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`, true)
671
- else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
672
-
673
- // Never heard from before is not suspicious by itself — everyone writes once for the
674
- // first time — but it is what turns a couple of small oddities into a pattern.
675
- if (!trusted && standing.received <= 1 && score > 0) add(1, 'This is the first message from this sender')
676
-
677
- // Someone this mailbox corresponds with is forgiven the small stuff; only findings heavy
678
- // enough to stand on their own still count against them.
679
- const limit = quarantineAt()
680
- if (trusted && score < limit) return { risk: 'clean', reasons: [], score: 0, quarantine: false }
681
-
682
- // One small oddity is not a case to answer. A subject in capitals from somebody writing
683
- // for the first time is a stranger in a hurry, not a scam, and saying otherwise every
684
- // time teaches the reader to ignore the warning.
685
- if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
686
-
687
- const quarantine = score >= limit && telling > 0
688
- return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
689
- }
690
529
 
691
530
 
692
531
  /** How the mailbox came to hold a message, from that mailbox's own point of view. */
@@ -1183,13 +1022,23 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
1183
1022
  }
1184
1023
  }
1185
1024
 
1186
- export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
1025
+ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam' | 'filtered'
1187
1026
 
1188
1027
  /** The newest conversations in a folder: one row each, already summarised. */
1189
1028
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
1190
1029
 
1191
- export async function listThreads(ownerRaw: string | null, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
1192
- await ensureMailSchema()
1030
+ export async function listThreads(
1031
+ ownerRaw: string | null,
1032
+ folder: ThreadFolder,
1033
+ limit: number,
1034
+ cursorRaw?: string | null,
1035
+ filters: InboxFilter[] = [],
1036
+ ): Promise<ThreadPage> {
1037
+ await ensureMailSchema()
1038
+ const filterSql = folder === 'inbox' || folder === 'filtered' ? inboxFiltersSql(filters) : null
1039
+ if (folder === 'filtered' && !filterSql) return { rows: [], nextCursor: null }
1040
+ const filterClause = filterSql ? (folder === 'filtered' ? filterSql.sql : `NOT ${filterSql.sql}`) : ''
1041
+ const filterArgs = filterSql?.args ?? []
1193
1042
  const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
1194
1043
  const nowIso = new Date().toISOString()
1195
1044
  const predicate =
@@ -1201,7 +1050,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1201
1050
  : 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1202
1051
  // Both snooze predicates carry one bound timestamp; the others carry none, and the
1203
1052
  // cursor's arguments have to follow whatever the predicate used.
1204
- const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
1053
+ const folderArgs = folder === 'snoozed' || folder === 'inbox' || folder === 'filtered' ? [nowIso] : []
1205
1054
  const cursor = decodeCursor(cursorRaw)
1206
1055
  const cursorClause = cursor ? '(latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''
1207
1056
  const cursorArgs = cursor ? [cursor.receivedAt, cursor.receivedAt, cursor.id] : []
@@ -1209,25 +1058,28 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1209
1058
  // conversation back together. SQLite fills a bare column from whichever row matched the
1210
1059
  // MAX in the same select, which is how subject, snippet and senders come from the latest
1211
1060
  // message rather than an arbitrary one.
1061
+ const outer = [filterClause, cursorClause].filter(Boolean).join(' AND ')
1212
1062
  const rows = owner === null
1213
1063
  ? await tagged(db(), `
1214
- SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1215
- SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1216
- SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1217
- SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1218
- senders, snippet, labels, snoozed_until, addressed, risk
1219
- FROM mail_threads
1220
- GROUP BY thread_id
1221
- HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
1064
+ SELECT * FROM (
1065
+ SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
1066
+ SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
1067
+ SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
1068
+ SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
1069
+ senders, snippet, labels, snoozed_until, addressed, risk
1070
+ FROM mail_threads
1071
+ GROUP BY thread_id
1072
+ HAVING ${predicate}
1073
+ ) t${outer ? ` WHERE ${outer}` : ''}
1222
1074
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1223
- [...folderArgs, ...cursorArgs, limit])
1075
+ [...folderArgs, ...filterArgs, ...cursorArgs, limit])
1224
1076
  : await tagged(db(), `
1225
1077
  SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
1226
1078
  inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, snoozed_until, addressed, risk
1227
- FROM mail_threads WHERE owner = ? AND ${predicate}
1228
- ${cursorClause ? `AND ${cursorClause}` : ''}
1079
+ FROM mail_threads t WHERE owner = ? AND ${predicate}
1080
+ ${outer ? `AND ${outer}` : ''}
1229
1081
  ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
1230
- [owner, ...folderArgs, ...cursorArgs, limit])
1082
+ [owner, ...folderArgs, ...filterArgs, ...cursorArgs, limit])
1231
1083
  const last = rows[rows.length - 1]
1232
1084
  const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
1233
1085
  const mapped = rows.map(row => ({
@@ -1254,6 +1106,27 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
1254
1106
  return { rows: mapped, nextCursor }
1255
1107
  }
1256
1108
 
1109
+ /** Inbox conversations the rules hold out, and how many of those have unread mail. */
1110
+ export async function countFilteredThreads(ownerRaw: string | null, filters: InboxFilter[]): Promise<{ total: number; unread: number }> {
1111
+ const filterSql = inboxFiltersSql(filters)
1112
+ if (!filterSql) return { total: 0, unread: 0 }
1113
+ await ensureMailSchema()
1114
+ const nowIso = new Date().toISOString()
1115
+ const inbox = 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
1116
+ const rows = ownerRaw === null
1117
+ ? await tagged(db(), `
1118
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread FROM (
1119
+ SELECT thread_id, SUM(unread_count) AS unread_count, SUM(inbox_count) AS inbox_count, snoozed_until, senders, subject, snippet, MAX(latest_at)
1120
+ FROM mail_threads GROUP BY thread_id HAVING ${inbox}
1121
+ ) t WHERE ${filterSql.sql}`,
1122
+ [nowIso, ...filterSql.args])
1123
+ : await tagged(db(), `
1124
+ SELECT COUNT(*) AS total, SUM(CASE WHEN unread_count > 0 THEN 1 ELSE 0 END) AS unread
1125
+ FROM mail_threads t WHERE owner = ? AND ${inbox} AND ${filterSql.sql}`,
1126
+ [ownerRaw.toLowerCase(), nowIso, ...filterSql.args])
1127
+ return { total: Number(rows[0]?.total ?? 0), unread: Number(rows[0]?.unread ?? 0) }
1128
+ }
1129
+
1257
1130
  /**
1258
1131
  * Backfill, phase one: give every message a thread id, oldest first so the thirty-day
1259
1132
  * rule holds for history. Summaries are left for the second phase, so each thread is
@@ -1337,7 +1210,7 @@ export async function appendInbound(
1337
1210
  * labels and owner are the reader's, not the repair's, and a row that already has a body
1338
1211
  * is left exactly as it is.
1339
1212
  */
1340
- export async function rejudgeStored(options: { before?: string; limit?: number } = {}): Promise<{
1213
+ export async function rejudgeStored(options: { before?: string; limit?: number; flaggedOnly?: boolean } = {}): Promise<{
1341
1214
  scanned: number
1342
1215
  changed: number
1343
1216
  quarantined: number
@@ -1351,7 +1224,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1351
1224
  SELECT id, owner, from_addr, reply_to, subject, body_text, headers, received_at,
1352
1225
  read, starred, archived, trashed, risk, spam
1353
1226
  FROM mail_inbox
1354
- WHERE received_at < ${before}
1227
+ WHERE received_at < ${before} AND (${options.flaggedOnly ? 1 : 0} = 0 OR coalesce(risk, 'clean') != 'clean')
1355
1228
  ORDER BY received_at DESC
1356
1229
  LIMIT ${limit}`
1357
1230
 
@@ -1360,6 +1233,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1360
1233
  result.cursor = String(rows[rows.length - 1].received_at ?? '')
1361
1234
 
1362
1235
  const standings = new Map<string, SenderStanding>()
1236
+ const earliest = new Map<string, string | null>()
1363
1237
  const touchedOwners = new Set<string>()
1364
1238
  for (const row of rows) {
1365
1239
  const owner = String(row.owner ?? '')
@@ -1367,7 +1241,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1367
1241
  const key = `${owner.toLowerCase()}\u0000${senderDomain}`
1368
1242
  let standing = standings.get(key)
1369
1243
  if (!standing) {
1370
- standing = await senderStanding(owner, senderDomain)
1244
+ standing = await senderStanding(owner, senderDomain, earliest)
1371
1245
  standings.set(key, standing)
1372
1246
  }
1373
1247
 
@@ -1384,6 +1258,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1384
1258
  replyTo: parseJson<string[]>(row.reply_to, []),
1385
1259
  subject: String(row.subject ?? ''),
1386
1260
  text: row.body_text == null ? null : String(row.body_text),
1261
+ receivedAt: String(row.received_at ?? ''),
1387
1262
  }, standing)
1388
1263
 
1389
1264
  // A message the reader has already read, starred, filed or binned stays exactly where
@@ -1718,6 +1593,30 @@ export async function setInboundSpam(id: string, spam: boolean): Promise<void> {
1718
1593
  await rethreadAfterChange(id)
1719
1594
  }
1720
1595
 
1596
+ /** Trusts the sender of one message for its mailbox, and lifts the warnings already on their mail there. */
1597
+ export async function trustSenderOf(id: string): Promise<string[]> {
1598
+ await ensureMailSchema()
1599
+ const sql = db()
1600
+ const rows = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
1601
+ const owner = rows[0]?.owner == null ? '' : String(rows[0].owner).toLowerCase()
1602
+ const domain = senderDomainOf(String(rows[0]?.from_addr ?? ''))
1603
+ if (!owner || !domain) return []
1604
+ const now = nowIso()
1605
+ await sql`
1606
+ INSERT INTO mail_sender_reputation (owner, domain, trusted, first_seen, last_seen)
1607
+ VALUES (${owner}, ${domain}, 1, ${now}, ${now})
1608
+ ON CONFLICT (owner, domain) DO UPDATE SET trusted = 1, marked_spam = 0`
1609
+ const match = domainMatch(domain)
1610
+ const flagged = await tagged(sql, `SELECT id FROM mail_inbox WHERE lower(owner) = ? AND risk = 'suspicious' AND ${match.sql}`, [owner, ...match.args])
1611
+ const ids = flagged.map(row => String(row.id))
1612
+ for (const flaggedId of ids) {
1613
+ await sql`UPDATE mail_inbox SET risk = 'clean', risk_reasons = '[]' WHERE id = ${flaggedId}`
1614
+ await rethreadAfterChange(flaggedId)
1615
+ }
1616
+ await invalidateCounts(owner)
1617
+ return ids
1618
+ }
1619
+
1721
1620
  export async function setInboundFlags(id: string, flags: InboundFlags): Promise<void> {
1722
1621
  const sql = db()
1723
1622
  if (flags.read !== undefined) await sql`UPDATE mail_inbox SET read = ${flags.read} WHERE id = ${id}`
@@ -1906,6 +1805,10 @@ export async function getSettings(owner: string): Promise<Record<string, unknown
1906
1805
  return parseJson<Record<string, unknown>>(rows[0]?.data, {})
1907
1806
  }
1908
1807
 
1808
+ export async function inboxFiltersFor(login: string): Promise<InboxFilter[]> {
1809
+ return normalizeInboxFilters((await getSettings(login)).inboxFilters)
1810
+ }
1811
+
1909
1812
  export async function setSettings(owner: string, data: Record<string, unknown>): Promise<void> {
1910
1813
  await ensureMailSchema()
1911
1814
  const sql = db()