@novacraft-engineering/mailbox 0.4.29 → 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.
@@ -2,7 +2,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
  import { NextResponse } from 'next/server'
3
3
  import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
4
4
  import { scopeFor } from '@/lib/scope'
5
- import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
5
+ import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundSpam, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, trustSenderOf, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
6
6
  import { addressedToUs, attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
7
7
  import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
8
8
 
@@ -57,7 +57,7 @@ export async function GET(req: Request) {
57
57
  export async function PATCH(req: Request) {
58
58
  const guard = await mailAuthGuard(req)
59
59
  if (guard) return guard
60
- let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean } & InboundFlags
60
+ let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null; spam?: boolean; trust?: boolean } & InboundFlags
61
61
  try {
62
62
  body = await req.json()
63
63
  } catch {
@@ -110,6 +110,10 @@ export async function PATCH(req: Request) {
110
110
  const changed = await setInboundFlagsForThread(account.address ?? ' no-address', body.threadId, flags)
111
111
  return NextResponse.json({ ok: true, ids: changed })
112
112
  }
113
+ if (body.trust === true) {
114
+ const cleared = (await Promise.all(ids.map(id => trustSenderOf(id)))).flat()
115
+ return NextResponse.json({ ok: true, ids: cleared })
116
+ }
113
117
  // Quarantine is its own move: it teaches the sender's standing, which a flag does not.
114
118
  if (body.spam !== undefined) {
115
119
  await Promise.all(ids.map(id => setInboundSpam(id, Boolean(body.spam))))
@@ -27,6 +27,7 @@ export async function POST(req: Request) {
27
27
  const result = await rejudgeStored({
28
28
  before: url.searchParams.get('before') ?? undefined,
29
29
  limit: Number(url.searchParams.get('limit') ?? 200) || 200,
30
+ flaggedOnly: url.searchParams.get('flagged') === '1',
30
31
  })
31
32
  return NextResponse.json({ ok: true, ...result })
32
33
  }
@@ -1268,6 +1268,18 @@
1268
1268
  .riskBannerWhy { display: block; margin-top: 3px; opacity: 0.92; font-size: 12.5px; }
1269
1269
  .riskBannerReasons { margin: 4px 0 0; padding-left: 16px; opacity: 0.92; font-size: 12.5px; list-style: disc; }
1270
1270
  .riskBannerReasons li { margin-top: 2px; }
1271
+ .riskBannerTrust {
1272
+ margin-top: 9px;
1273
+ padding: 6px 11px;
1274
+ border: 1px solid rgba(255, 255, 255, 0.75);
1275
+ border-radius: 6px;
1276
+ background: transparent;
1277
+ color: #fff;
1278
+ font: 600 12px/1 var(--nc-font-sans);
1279
+ cursor: pointer;
1280
+ }
1281
+ .riskBannerTrust:hover { background: rgba(255, 255, 255, 0.14); }
1282
+ .riskBannerTrust:disabled { opacity: 0.7; cursor: default; }
1271
1283
 
1272
1284
  .riskNoteIcon,
1273
1285
  .riskBannerIcon { display: inline-flex; flex-shrink: 0; }
package/app/mail/page.tsx CHANGED
@@ -57,6 +57,7 @@ import Ticker, { TICKER_SPOTS, tickerDefault, tickerSettingsFrom, type TickerSet
57
57
  import { splitQuotedTail, splitQuotedText } from './quoted'
58
58
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
59
59
  import { matchesInboxFilters, normalizeInboxFilters, type InboxFilter } from '@/lib/inbox-filters'
60
+ import { FIRST_MESSAGE_REASON } from '@/lib/risk'
60
61
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
61
62
  import styles from './page.module.css'
62
63
 
@@ -3714,6 +3715,34 @@ export default function DevMailPage() {
3714
3715
  * against the sender, rescuing counts for them, and the next message from that domain
3715
3716
  * is judged with the answer already in hand.
3716
3717
  */
3718
+ const [trustingId, setTrustingId] = useState<string | null>(null)
3719
+ const trustSender = useCallback(
3720
+ async (entry: InboundEmail) => {
3721
+ setTrustingId(entry.id)
3722
+ const response = await fetch('/api/mail/inbox', {
3723
+ method: 'PATCH',
3724
+ headers: apiHeaders(),
3725
+ body: JSON.stringify({ ids: [entry.id], trust: true }),
3726
+ }).catch(() => null)
3727
+ const data = await response?.json().catch(() => null)
3728
+ setTrustingId(null)
3729
+ if (!data?.ok) {
3730
+ setSentFlash('Could not trust this sender. Try again.')
3731
+ window.setTimeout(() => setSentFlash(''), 2500)
3732
+ return
3733
+ }
3734
+ const cleared = new Set<string>([entry.id, ...((data.ids as string[] | undefined) ?? [])])
3735
+ setInboxEmails(list => list.map(item => (cleared.has(item.id) ? { ...item, risk: 'clean', riskReasons: [] } : item)))
3736
+ const domain = parseAddress(entry.from).split('@')[1] ?? 'this sender'
3737
+ setSentFlash(`Mail from ${domain} is trusted now`)
3738
+ window.setTimeout(() => setSentFlash(''), 2500)
3739
+ threadsLoadedFolder.current = null
3740
+ threadsFetch.current = null
3741
+ loadThreads()
3742
+ },
3743
+ [apiHeaders, loadThreads],
3744
+ )
3745
+
3717
3746
  const markSpam = useCallback(
3718
3747
  async (ids: string[], spam: boolean) => {
3719
3748
  if (!ids.length) return
@@ -6498,6 +6527,16 @@ export default function DevMailPage() {
6498
6527
  <span className={styles.riskBannerWhy}>
6499
6528
  Treat links and attachments here with care, and do not enter passwords or payment details.
6500
6529
  </span>
6530
+ {inbound.risk === 'suspicious' && (inbound.riskReasons ?? []).includes(FIRST_MESSAGE_REASON) && (
6531
+ <button
6532
+ type="button"
6533
+ className={styles.riskBannerTrust}
6534
+ disabled={trustingId === inbound.id}
6535
+ onClick={() => void trustSender(inbound)}
6536
+ >
6537
+ {trustingId === inbound.id ? 'Trusting…' : 'Trust this sender'}
6538
+ </button>
6539
+ )}
6501
6540
  </span>
6502
6541
  </div>
6503
6542
  )}
package/lib/mailbox.ts CHANGED
@@ -14,6 +14,9 @@ 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
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 }
17
20
 
18
21
  export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
19
22
 
@@ -387,6 +390,7 @@ export function ensureMailSchema(): Promise<void> {
387
390
  await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN spam INTEGER NOT NULL DEFAULT 0").catch(() => {})
388
391
  await sqlRaw("CREATE INDEX IF NOT EXISTS mail_inbox_spam_idx ON mail_inbox (lower(owner), spam, received_at DESC)").catch(() => {})
389
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(() => {})
390
394
  // The row in the list follows its newest message, so the conversation carries it too.
391
395
  await sqlRaw("ALTER TABLE mail_threads ADD COLUMN addressed TEXT").catch(() => {})
392
396
  // Worst verdict in the conversation, so a warning cannot hide behind a later reply.
@@ -455,30 +459,47 @@ export function ensureMailSchema(): Promise<void> {
455
459
 
456
460
  // ── Inbox ──────────────────────────────────────────────────────
457
461
 
458
- export type SenderStanding = {
459
- received: number
460
- trashed: number
461
- markedSpam: number
462
- replied: number
463
- 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)
464
477
  }
465
478
 
466
479
  /** What this mailbox has done with this sender's domain before. */
467
- export async function senderStanding(owner: string | null, domain: string): Promise<SenderStanding> {
468
- 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 }
469
486
  if (!owner || !domain) return empty
470
487
  await ensureMailSchema()
471
488
  const rows = await db()`
472
- 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
473
490
  WHERE owner = ${owner.toLowerCase()} AND domain = ${domain.toLowerCase()}`
474
491
  const row = rows[0]
475
- 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)
476
496
  return {
477
- received: Number(row.received ?? 0),
478
- trashed: Number(row.trashed ?? 0),
479
- markedSpam: Number(row.marked_spam ?? 0),
480
- replied: Number(row.replied ?? 0),
481
- 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,
482
503
  }
483
504
  }
484
505
 
@@ -505,189 +526,6 @@ export async function noteSender(
505
526
  last_seen = ${now}`
506
527
  }
507
528
 
508
- /** What the scanners and the sender's own domain said. */
509
- export type Risk = 'clean' | 'suspicious' | 'spam' | 'virus'
510
-
511
- export type RiskSignals = {
512
- spam?: string | null
513
- virus?: string | null
514
- spf?: string | null
515
- dkim?: string | null
516
- dmarc?: string | null
517
- /** The message itself, for the tells authentication cannot see. */
518
- from?: string | null
519
- replyTo?: string[] | null
520
- subject?: string | null
521
- text?: string | null
522
- }
523
-
524
- /** Defaults only. Each is overridable per deployment, so a list can change without a
525
- * release — metroperil can drop a word its own trade uses every day. */
526
- const FREE_MAIL_DEFAULT = new Set([
527
- 'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'hotmail.com', 'outlook.com',
528
- 'live.com', 'aol.com', 'protonmail.com', 'proton.me', 'mail.com', 'gmx.com', 'yandex.com',
529
- 'icloud.com', 'zoho.com', 'inbox.lv', 'consultant.com', 'qq.com', '163.com',
530
- ])
531
-
532
- const THROWAWAY_TLDS_DEFAULT = new Set([
533
- 'xyz', 'top', 'buzz', 'click', 'link', 'work', 'gq', 'cf', 'ml', 'tk', 'ga',
534
- 'loan', 'men', 'date', 'racing', 'win', 'stream', 'download', 'review', 'country', 'kim',
535
- ])
536
-
537
- /** The shape of an advance-fee approach. Counted, never single-word: one alone is innocent. */
538
- // Only wording that is odd in ordinary business correspondence belongs here. A single
539
- // generic term is not evidence of anything: an insurance broker writes "beneficiary" and
540
- // "bank draft" all day, a logistics firm writes "consignment", and every sales team sends
541
- // a "business proposal". Add them per deployment through MAIL_SCAM_PHRASES if a mailbox
542
- // genuinely never sees them.
543
- const SCAM_PHRASES_DEFAULT = [
544
- 'next of kin', 'sole beneficiary', 'late client', 'deceased client',
545
- 'inheritance', 'died without', 'without a will', 'unclaimed inheritance',
546
- 'winning notification', 'lottery winner', 'western union', 'atm card',
547
- 'transfer to your account immediately', 'strictly confidential and urgent',
548
- ]
549
-
550
- /** The registrable domain behind an address, for reputation to be keyed on. */
551
- export const senderDomainOf = (address: string): string => registrable(domainOf(address))
552
-
553
- const domainOf = (address: string): string => {
554
- const angled = address.match(/<([^>]+)>/)
555
- const bare = (angled ? angled[1] : address).trim().toLowerCase()
556
- return bare.split('@').pop() ?? ''
557
- }
558
-
559
- /** example.co.uk and example.com both reduce to the name somebody actually registered. */
560
- const registrable = (host: string): string => {
561
- const parts = host.split('.').filter(Boolean)
562
- if (parts.length <= 2) return parts.join('.')
563
- const twoLevel = /^(co|com|org|net|gov|ac|edu|ltd|plc)\.[a-z]{2}$/.test(parts.slice(-2).join('.'))
564
- return parts.slice(twoLevel ? -3 : -2).join('.')
565
- }
566
-
567
- const failed = (verdict: string | null | undefined): boolean =>
568
- typeof verdict === 'string' && /^(fail|softfail|permerror)$/i.test(verdict.trim())
569
-
570
- const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<string> => {
571
- const parsed = (raw ?? '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
572
- return parsed.length ? new Set(parsed) : new Set(fallback)
573
- }
574
-
575
- // Read per call, so a deployment can change any of them without a release.
576
- const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
577
- const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
578
-
579
- // Bulk senders put their own bounce domain in From and the real correspondent in Reply-To.
580
- // That is how the campaign gets replies, not an attempt to redirect them somewhere unexpected.
581
- const BULK_SENDERS_DEFAULT = new Set([
582
- 'mailchimpapp.com', 'mcsv.net', 'rsgsv.net', 'mailchimp.com',
583
- 'sendgrid.net', 'sendgrid.com', 'sparkpostmail.com', 'amazonses.com',
584
- 'mailgun.org', 'mandrillapp.com', 'postmarkapp.com', 'sendinblue.com',
585
- 'brevo.com', 'constantcontact.com', 'cmail19.com', 'createsend.com',
586
- 'hubspotemail.net', 'mailerlite.com', 'klaviyomail.com', 'salesforce.com',
587
- ])
588
- const bulkSenders = () => listFrom(process.env.MAIL_BULK_SENDERS, BULK_SENDERS_DEFAULT)
589
- const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
590
-
591
- /** Weight at which a message stops being labelled and is held out of the inbox instead. */
592
- const quarantineAt = () => Number(process.env.MAIL_SPAM_THRESHOLD ?? 6)
593
-
594
- /** Below this nothing is said at all. One small oddity is not a case. */
595
- const flagAt = () => Number(process.env.MAIL_SUSPICION_THRESHOLD ?? 3)
596
-
597
- export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quarantine: boolean }
598
-
599
- /**
600
- * What this mailbox knows, then what is true of the message. The standing a sender has
601
- * built here leads: somebody you have written back to is not spam because their subject
602
- * shouts, and somebody whose mail you have binned repeatedly does not get the benefit of
603
- * the doubt again. The fixed rules only decide the cases with no history to go on, and
604
- * every one of their lists can be changed per deployment without a release.
605
- */
606
- export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
607
- const reasons: string[] = []
608
- let score = 0
609
- // A finding is "telling" when it is hard to trip by accident. Failing an authentication
610
- // check or shouting in the subject line is neither: ordinary mail does both. Holding a
611
- // message back takes at least one finding of the first kind, however the weights add up.
612
- let telling = 0
613
- const add = (weight: number, why: string, isTelling = false) => {
614
- score += weight
615
- if (isTelling) telling += 1
616
- reasons.push(why)
617
- }
618
-
619
- if (/^fail$/i.test((signals.virus ?? '').trim())) {
620
- return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
621
- }
622
-
623
- // Trust is earned by being written back to, never by volume alone: a sender whose mail
624
- // arrives forty times and is binned every time has not earned anything.
625
- const trusted = standing.replied > 0 && standing.markedSpam === 0
626
- if (standing.markedSpam > 0) {
627
- add(4 + Math.min(standing.markedSpam, 4),
628
- `You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`, true)
629
- } else if (standing.trashed >= 3 && standing.replied === 0) {
630
- add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`, true)
631
- }
632
-
633
- if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message', true)
634
-
635
- const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
636
- // Heavy, but not enough on its own to hide a message: mail forwarded through a list
637
- // breaks alignment and fails DMARC while being perfectly legitimate. It warns loudly;
638
- // it takes a second finding to put a message out of sight.
639
- if (failed(signals.dmarc)) add(4, 'The sending domain says this message is not from them (DMARC failed)')
640
- else if (!authenticated) {
641
- if (failed(signals.spf)) add(2, 'The sending server is not authorised by that domain (SPF failed)')
642
- if (failed(signals.dkim)) add(2, 'The signature does not match the sending domain (DKIM failed)')
643
- }
644
-
645
- const fromDomain = registrable(domainOf(signals.from ?? ''))
646
- const replyDomains = (signals.replyTo ?? [])
647
- .map(entry => registrable(domainOf(entry)))
648
- .filter(entry => entry && entry !== fromDomain)
649
- const free = freeProviders()
650
- const freeReply = replyDomains.find(entry => free.has(entry))
651
- const bulk = bulkSenders().has(fromDomain)
652
- if (bulk) {
653
- // Nothing to say: a campaign's replies are meant to land somewhere other than the
654
- // sending platform, and treating that as misdirection buries ordinary bulk mail.
655
- } else if (freeReply && fromDomain && !free.has(fromDomain)) {
656
- add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`, true)
657
- } else if (replyDomains.length) {
658
- add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
659
- }
660
-
661
- const tld = fromDomain.split('.').pop() ?? ''
662
- if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`, true)
663
- if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits', true)
664
-
665
- const subject = (signals.subject ?? '').trim()
666
- const letters = subject.replace(/[^A-Za-z]/g, '')
667
- if (letters.length >= 12 && letters === letters.toUpperCase()) add(1, 'The subject is written entirely in capitals')
668
-
669
- const body = (signals.text ?? '').toLowerCase()
670
- const hits = scamPhrases().filter(phrase => body.includes(phrase))
671
- if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`, true)
672
- else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
673
-
674
- // Never heard from before is not suspicious by itself — everyone writes once for the
675
- // first time — but it is what turns a couple of small oddities into a pattern.
676
- if (!trusted && standing.received <= 1 && score > 0) add(1, 'This is the first message from this sender')
677
-
678
- // Someone this mailbox corresponds with is forgiven the small stuff; only findings heavy
679
- // enough to stand on their own still count against them.
680
- const limit = quarantineAt()
681
- if (trusted && score < limit) return { risk: 'clean', reasons: [], score: 0, quarantine: false }
682
-
683
- // One small oddity is not a case to answer. A subject in capitals from somebody writing
684
- // for the first time is a stranger in a hurry, not a scam, and saying otherwise every
685
- // time teaches the reader to ignore the warning.
686
- if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
687
-
688
- const quarantine = score >= limit && telling > 0
689
- return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
690
- }
691
529
 
692
530
 
693
531
  /** How the mailbox came to hold a message, from that mailbox's own point of view. */
@@ -1372,7 +1210,7 @@ export async function appendInbound(
1372
1210
  * labels and owner are the reader's, not the repair's, and a row that already has a body
1373
1211
  * is left exactly as it is.
1374
1212
  */
1375
- export async function rejudgeStored(options: { before?: string; limit?: number } = {}): Promise<{
1213
+ export async function rejudgeStored(options: { before?: string; limit?: number; flaggedOnly?: boolean } = {}): Promise<{
1376
1214
  scanned: number
1377
1215
  changed: number
1378
1216
  quarantined: number
@@ -1386,7 +1224,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1386
1224
  SELECT id, owner, from_addr, reply_to, subject, body_text, headers, received_at,
1387
1225
  read, starred, archived, trashed, risk, spam
1388
1226
  FROM mail_inbox
1389
- WHERE received_at < ${before}
1227
+ WHERE received_at < ${before} AND (${options.flaggedOnly ? 1 : 0} = 0 OR coalesce(risk, 'clean') != 'clean')
1390
1228
  ORDER BY received_at DESC
1391
1229
  LIMIT ${limit}`
1392
1230
 
@@ -1395,6 +1233,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1395
1233
  result.cursor = String(rows[rows.length - 1].received_at ?? '')
1396
1234
 
1397
1235
  const standings = new Map<string, SenderStanding>()
1236
+ const earliest = new Map<string, string | null>()
1398
1237
  const touchedOwners = new Set<string>()
1399
1238
  for (const row of rows) {
1400
1239
  const owner = String(row.owner ?? '')
@@ -1402,7 +1241,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1402
1241
  const key = `${owner.toLowerCase()}\u0000${senderDomain}`
1403
1242
  let standing = standings.get(key)
1404
1243
  if (!standing) {
1405
- standing = await senderStanding(owner, senderDomain)
1244
+ standing = await senderStanding(owner, senderDomain, earliest)
1406
1245
  standings.set(key, standing)
1407
1246
  }
1408
1247
 
@@ -1419,6 +1258,7 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
1419
1258
  replyTo: parseJson<string[]>(row.reply_to, []),
1420
1259
  subject: String(row.subject ?? ''),
1421
1260
  text: row.body_text == null ? null : String(row.body_text),
1261
+ receivedAt: String(row.received_at ?? ''),
1422
1262
  }, standing)
1423
1263
 
1424
1264
  // A message the reader has already read, starred, filed or binned stays exactly where
@@ -1753,6 +1593,30 @@ export async function setInboundSpam(id: string, spam: boolean): Promise<void> {
1753
1593
  await rethreadAfterChange(id)
1754
1594
  }
1755
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
+
1756
1620
  export async function setInboundFlags(id: string, flags: InboundFlags): Promise<void> {
1757
1621
  const sql = db()
1758
1622
  if (flags.read !== undefined) await sql`UPDATE mail_inbox SET read = ${flags.read} WHERE id = ${id}`
package/lib/receive.ts CHANGED
@@ -323,6 +323,7 @@ export async function ingestReceived(
323
323
  replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
324
324
  subject: full?.subject || String(data.subject ?? ''),
325
325
  text: full?.text ?? (data.text ? String(data.text) : null),
326
+ receivedAt: full?.createdAt || String(data.created_at ?? new Date().toISOString()),
326
327
  }, standing)
327
328
  if (verdicts.risk !== 'clean') {
328
329
  console.warn(`[mail] ${verdicts.risk} inbound ${emailId}:`, verdicts.reasons.join('; '))
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ process.env.MAIL_ADDRESS_DOMAIN = 'example.com'
4
+ const { judgeMessage, FIRST_MESSAGE_REASON } = await import('./risk.ts')
5
+ type Standing = Parameters<typeof judgeMessage>[1]
6
+
7
+ const stranger: Standing = { received: 0, trashed: 0, markedSpam: 0, replied: 0, trusted: false, firstSeen: null }
8
+ const known = (firstSeen: string, extra: Partial<Standing> = {}): Standing => ({ ...stranger, received: 2, firstSeen, ...extra })
9
+
10
+ const promo = { from: 'Swift <customercare@swiftng.net>', spf: 'fail', subject: 'We Want You Back', receivedAt: '2026-09-23T20:36:05.281Z' }
11
+ assert.equal(judgeMessage(promo, known('2022-01-10T10:49:25.000Z')).risk, 'clean', 'a sender with years of history is not writing for the first time')
12
+ const firstTime = judgeMessage(promo, stranger)
13
+ assert.equal(firstTime.risk, 'suspicious')
14
+ assert.ok(firstTime.reasons.includes(FIRST_MESSAGE_REASON), 'a real first message still says so')
15
+ assert.ok(!judgeMessage(promo, known('2026-09-23T20:30:00.000Z')).reasons.includes(FIRST_MESSAGE_REASON), 'the second message is not the first')
16
+ assert.ok(judgeMessage(promo, known(promo.receivedAt)).reasons.includes(FIRST_MESSAGE_REASON), 'the earliest stored message is the first, when re-judged')
17
+
18
+ const enquiry = {
19
+ from: 'Example Website <no-reply@example.com>',
20
+ replyTo: ['someone@gmail.com'],
21
+ dmarc: 'pass',
22
+ spf: 'pass',
23
+ dkim: 'pass',
24
+ subject: 'New enquiry — Motor insurance',
25
+ receivedAt: '2026-09-14T07:24:40.046Z',
26
+ }
27
+ assert.equal(judgeMessage(enquiry, stranger).risk, 'clean', 'our own authenticated form mail is not a stranger misdirecting replies')
28
+ const forged = judgeMessage({ ...enquiry, dmarc: 'fail', spf: 'fail', dkim: 'fail' }, stranger)
29
+ assert.notEqual(forged.risk, 'clean', 'mail that only claims our domain earns nothing')
30
+ assert.equal(judgeMessage({ ...enquiry, from: 'Web <no-reply@example.org>' }, stranger).risk, 'suspicious', 'another domain doing the same is still flagged')
31
+
32
+ assert.equal(judgeMessage(promo, { ...stranger, trusted: true }).risk, 'clean', 'a trusted sender is forgiven the small stuff')
33
+ assert.notEqual(judgeMessage(promo, { ...stranger, trusted: true, markedSpam: 2 }).risk, 'clean', 'marking spam outranks trust')
34
+
35
+ console.log('risk: ok')
package/lib/risk.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { ADDRESS_DOMAINS } from './brand.ts'
2
+
3
+ export type SenderStanding = {
4
+ received: number
5
+ trashed: number
6
+ markedSpam: number
7
+ replied: number
8
+ /** Someone in this mailbox said so. */
9
+ trusted: boolean
10
+ /** The earliest mail this mailbox holds from the sender's domain, whoever it went to. */
11
+ firstSeen: string | null
12
+ }
13
+
14
+ export const FIRST_MESSAGE_REASON = 'This is the first message from this sender'
15
+
16
+ /** What the scanners and the sender's own domain said. */
17
+ export type Risk = 'clean' | 'suspicious' | 'spam' | 'virus'
18
+
19
+ export type RiskSignals = {
20
+ spam?: string | null
21
+ virus?: string | null
22
+ spf?: string | null
23
+ dkim?: string | null
24
+ dmarc?: string | null
25
+ /** The message itself, for the tells authentication cannot see. */
26
+ from?: string | null
27
+ replyTo?: string[] | null
28
+ subject?: string | null
29
+ text?: string | null
30
+ receivedAt?: string | null
31
+ }
32
+
33
+ /** Defaults only. Each is overridable per deployment, so a list can change without a
34
+ * release — metroperil can drop a word its own trade uses every day. */
35
+ const FREE_MAIL_DEFAULT = new Set([
36
+ 'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'hotmail.com', 'outlook.com',
37
+ 'live.com', 'aol.com', 'protonmail.com', 'proton.me', 'mail.com', 'gmx.com', 'yandex.com',
38
+ 'icloud.com', 'zoho.com', 'inbox.lv', 'consultant.com', 'qq.com', '163.com',
39
+ ])
40
+
41
+ const THROWAWAY_TLDS_DEFAULT = new Set([
42
+ 'xyz', 'top', 'buzz', 'click', 'link', 'work', 'gq', 'cf', 'ml', 'tk', 'ga',
43
+ 'loan', 'men', 'date', 'racing', 'win', 'stream', 'download', 'review', 'country', 'kim',
44
+ ])
45
+
46
+ /** The shape of an advance-fee approach. Counted, never single-word: one alone is innocent. */
47
+ // Only wording that is odd in ordinary business correspondence belongs here. A single
48
+ // generic term is not evidence of anything: an insurance broker writes "beneficiary" and
49
+ // "bank draft" all day, a logistics firm writes "consignment", and every sales team sends
50
+ // a "business proposal". Add them per deployment through MAIL_SCAM_PHRASES if a mailbox
51
+ // genuinely never sees them.
52
+ const SCAM_PHRASES_DEFAULT = [
53
+ 'next of kin', 'sole beneficiary', 'late client', 'deceased client',
54
+ 'inheritance', 'died without', 'without a will', 'unclaimed inheritance',
55
+ 'winning notification', 'lottery winner', 'western union', 'atm card',
56
+ 'transfer to your account immediately', 'strictly confidential and urgent',
57
+ ]
58
+
59
+ /** The registrable domain behind an address, for reputation to be keyed on. */
60
+ export const senderDomainOf = (address: string): string => registrable(domainOf(address))
61
+
62
+ const domainOf = (address: string): string => {
63
+ const angled = address.match(/<([^>]+)>/)
64
+ const bare = (angled ? angled[1] : address).trim().toLowerCase()
65
+ return bare.split('@').pop() ?? ''
66
+ }
67
+
68
+ /** example.co.uk and example.com both reduce to the name somebody actually registered. */
69
+ const registrable = (host: string): string => {
70
+ const parts = host.split('.').filter(Boolean)
71
+ if (parts.length <= 2) return parts.join('.')
72
+ const twoLevel = /^(co|com|org|net|gov|ac|edu|ltd|plc)\.[a-z]{2}$/.test(parts.slice(-2).join('.'))
73
+ return parts.slice(twoLevel ? -3 : -2).join('.')
74
+ }
75
+
76
+ const failed = (verdict: string | null | undefined): boolean =>
77
+ typeof verdict === 'string' && /^(fail|softfail|permerror)$/i.test(verdict.trim())
78
+
79
+ const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<string> => {
80
+ const parsed = (raw ?? '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
81
+ return parsed.length ? new Set(parsed) : new Set(fallback)
82
+ }
83
+
84
+ // Read per call, so a deployment can change any of them without a release.
85
+ const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
86
+ const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
87
+
88
+ // Bulk senders put their own bounce domain in From and the real correspondent in Reply-To.
89
+ // That is how the campaign gets replies, not an attempt to redirect them somewhere unexpected.
90
+ const BULK_SENDERS_DEFAULT = new Set([
91
+ 'mailchimpapp.com', 'mcsv.net', 'rsgsv.net', 'mailchimp.com',
92
+ 'sendgrid.net', 'sendgrid.com', 'sparkpostmail.com', 'amazonses.com',
93
+ 'mailgun.org', 'mandrillapp.com', 'postmarkapp.com', 'sendinblue.com',
94
+ 'brevo.com', 'constantcontact.com', 'cmail19.com', 'createsend.com',
95
+ 'hubspotemail.net', 'mailerlite.com', 'klaviyomail.com', 'salesforce.com',
96
+ ])
97
+ const bulkSenders = () => listFrom(process.env.MAIL_BULK_SENDERS, BULK_SENDERS_DEFAULT)
98
+ const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
99
+
100
+ /** Weight at which a message stops being labelled and is held out of the inbox instead. */
101
+ const quarantineAt = () => Number(process.env.MAIL_SPAM_THRESHOLD ?? 6)
102
+
103
+ /** Below this nothing is said at all. One small oddity is not a case. */
104
+ const flagAt = () => Number(process.env.MAIL_SUSPICION_THRESHOLD ?? 3)
105
+
106
+ export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quarantine: boolean }
107
+
108
+ /**
109
+ * What this mailbox knows, then what is true of the message. The standing a sender has
110
+ * built here leads: somebody you have written back to is not spam because their subject
111
+ * shouts, and somebody whose mail you have binned repeatedly does not get the benefit of
112
+ * the doubt again. The fixed rules only decide the cases with no history to go on, and
113
+ * every one of their lists can be changed per deployment without a release.
114
+ */
115
+ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
116
+ const reasons: string[] = []
117
+ let score = 0
118
+ // A finding is "telling" when it is hard to trip by accident. Failing an authentication
119
+ // check or shouting in the subject line is neither: ordinary mail does both. Holding a
120
+ // message back takes at least one finding of the first kind, however the weights add up.
121
+ let telling = 0
122
+ const add = (weight: number, why: string, isTelling = false) => {
123
+ score += weight
124
+ if (isTelling) telling += 1
125
+ reasons.push(why)
126
+ }
127
+
128
+ if (/^fail$/i.test((signals.virus ?? '').trim())) {
129
+ return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
130
+ }
131
+
132
+ // Trust is earned by being written back to, never by volume alone: a sender whose mail
133
+ // arrives forty times and is binned every time has not earned anything.
134
+ const fromDomain = registrable(domainOf(signals.from ?? ''))
135
+ const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
136
+ // Our own domain, proven by DMARC: the website's forms and the platform's own notices.
137
+ // They set Reply-To to the customer on purpose and arrive from an address nobody writes back to.
138
+ const ownDomain = authenticated && ADDRESS_DOMAINS.some(domain => registrable(domain) === fromDomain)
139
+ const trusted = (standing.replied > 0 || standing.trusted || ownDomain) && standing.markedSpam === 0
140
+ if (standing.markedSpam > 0) {
141
+ add(4 + Math.min(standing.markedSpam, 4),
142
+ `You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`, true)
143
+ } else if (standing.trashed >= 3 && standing.replied === 0) {
144
+ add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`, true)
145
+ }
146
+
147
+ if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message', true)
148
+
149
+ // Heavy, but not enough on its own to hide a message: mail forwarded through a list
150
+ // breaks alignment and fails DMARC while being perfectly legitimate. It warns loudly;
151
+ // it takes a second finding to put a message out of sight.
152
+ if (failed(signals.dmarc)) add(4, 'The sending domain says this message is not from them (DMARC failed)')
153
+ else if (!authenticated) {
154
+ if (failed(signals.spf)) add(2, 'The sending server is not authorised by that domain (SPF failed)')
155
+ if (failed(signals.dkim)) add(2, 'The signature does not match the sending domain (DKIM failed)')
156
+ }
157
+
158
+ const replyDomains = (signals.replyTo ?? [])
159
+ .map(entry => registrable(domainOf(entry)))
160
+ .filter(entry => entry && entry !== fromDomain)
161
+ const free = freeProviders()
162
+ const freeReply = replyDomains.find(entry => free.has(entry))
163
+ const bulk = bulkSenders().has(fromDomain)
164
+ if (bulk) {
165
+ // Nothing to say: a campaign's replies are meant to land somewhere other than the
166
+ // sending platform, and treating that as misdirection buries ordinary bulk mail.
167
+ } else if (freeReply && fromDomain && !free.has(fromDomain)) {
168
+ add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`, true)
169
+ } else if (replyDomains.length) {
170
+ add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
171
+ }
172
+
173
+ const tld = fromDomain.split('.').pop() ?? ''
174
+ if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`, true)
175
+ if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits', true)
176
+
177
+ const subject = (signals.subject ?? '').trim()
178
+ const letters = subject.replace(/[^A-Za-z]/g, '')
179
+ if (letters.length >= 12 && letters === letters.toUpperCase()) add(1, 'The subject is written entirely in capitals')
180
+
181
+ const body = (signals.text ?? '').toLowerCase()
182
+ const hits = scamPhrases().filter(phrase => body.includes(phrase))
183
+ if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`, true)
184
+ else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
185
+
186
+ // Never heard from before is not suspicious by itself — everyone writes once for the
187
+ // first time — but it is what turns a couple of small oddities into a pattern.
188
+ const seenBefore = Boolean(standing.firstSeen) && (!signals.receivedAt || standing.firstSeen! < signals.receivedAt)
189
+ if (!trusted && !seenBefore && score > 0) add(1, FIRST_MESSAGE_REASON)
190
+
191
+ // Someone this mailbox corresponds with is forgiven the small stuff; only findings heavy
192
+ // enough to stand on their own still count against them.
193
+ const limit = quarantineAt()
194
+ if (trusted && score < limit) return { risk: 'clean', reasons: [], score: 0, quarantine: false }
195
+
196
+ // One small oddity is not a case to answer. A subject in capitals from somebody writing
197
+ // for the first time is a stranger in a hurry, not a scam, and saying otherwise every
198
+ // time teaches the reader to ignore the warning.
199
+ if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
200
+
201
+ const quarantine = score >= limit && telling > 0
202
+ return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",