@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.
- package/app/api/mail/inbox/route.ts +6 -2
- package/app/api/mail/maintenance/rejudge/route.ts +1 -0
- package/app/mail/page.module.css +12 -0
- package/app/mail/page.tsx +39 -0
- package/lib/mailbox.ts +65 -201
- package/lib/receive.ts +1 -0
- package/lib/risk.test.ts +35 -0
- package/lib/risk.ts +203 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
package/app/mail/page.module.css
CHANGED
|
@@ -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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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(
|
|
468
|
-
|
|
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
|
-
|
|
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
|
|
478
|
-
trashed: Number(row
|
|
479
|
-
markedSpam: Number(row
|
|
480
|
-
replied: Number(row
|
|
481
|
-
|
|
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('; '))
|
package/lib/risk.test.ts
ADDED
|
@@ -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.
|
|
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",
|