@novacraft-engineering/mailbox 0.4.17 → 0.4.19
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/mail/page.module.css +2 -0
- package/app/mail/page.tsx +5 -1
- package/lib/mailbox.ts +24 -11
- package/lib/schema.test.ts +50 -0
- package/package.json +1 -1
package/app/mail/page.module.css
CHANGED
|
@@ -1086,6 +1086,8 @@
|
|
|
1086
1086
|
|
|
1087
1087
|
.riskBanner strong { display: block; font-weight: 650; }
|
|
1088
1088
|
.riskBannerWhy { display: block; margin-top: 3px; opacity: 0.92; font-size: 12.5px; }
|
|
1089
|
+
.riskBannerReasons { margin: 4px 0 0; padding-left: 16px; opacity: 0.92; font-size: 12.5px; list-style: disc; }
|
|
1090
|
+
.riskBannerReasons li { margin-top: 2px; }
|
|
1089
1091
|
|
|
1090
1092
|
.riskNoteIcon,
|
|
1091
1093
|
.riskBannerIcon { display: inline-flex; flex-shrink: 0; }
|
package/app/mail/page.tsx
CHANGED
|
@@ -6312,7 +6312,11 @@ export default function DevMailPage() {
|
|
|
6312
6312
|
: 'This message may not be from who it claims'}
|
|
6313
6313
|
</strong>
|
|
6314
6314
|
{(inbound.riskReasons ?? []).length > 0 && (
|
|
6315
|
-
<
|
|
6315
|
+
<ul className={styles.riskBannerReasons}>
|
|
6316
|
+
{(inbound.riskReasons ?? []).map(reason => (
|
|
6317
|
+
<li key={reason}>{reason}</li>
|
|
6318
|
+
))}
|
|
6319
|
+
</ul>
|
|
6316
6320
|
)}
|
|
6317
6321
|
<span className={styles.riskBannerWhy}>
|
|
6318
6322
|
Treat links and attachments here with care, and do not enter passwords or payment details.
|
package/lib/mailbox.ts
CHANGED
|
@@ -593,7 +593,15 @@ export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quar
|
|
|
593
593
|
export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
|
|
594
594
|
const reasons: string[] = []
|
|
595
595
|
let score = 0
|
|
596
|
-
|
|
596
|
+
// A finding is "telling" when it is hard to trip by accident. Failing an authentication
|
|
597
|
+
// check or shouting in the subject line is neither: ordinary mail does both. Holding a
|
|
598
|
+
// message back takes at least one finding of the first kind, however the weights add up.
|
|
599
|
+
let telling = 0
|
|
600
|
+
const add = (weight: number, why: string, isTelling = false) => {
|
|
601
|
+
score += weight
|
|
602
|
+
if (isTelling) telling += 1
|
|
603
|
+
reasons.push(why)
|
|
604
|
+
}
|
|
597
605
|
|
|
598
606
|
if (/^fail$/i.test((signals.virus ?? '').trim())) {
|
|
599
607
|
return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
|
|
@@ -604,12 +612,12 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
604
612
|
const trusted = standing.replied > 0 && standing.markedSpam === 0
|
|
605
613
|
if (standing.markedSpam > 0) {
|
|
606
614
|
add(4 + Math.min(standing.markedSpam, 4),
|
|
607
|
-
`You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam
|
|
615
|
+
`You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`, true)
|
|
608
616
|
} else if (standing.trashed >= 3 && standing.replied === 0) {
|
|
609
|
-
add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying
|
|
617
|
+
add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`, true)
|
|
610
618
|
}
|
|
611
619
|
|
|
612
|
-
if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message')
|
|
620
|
+
if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message', true)
|
|
613
621
|
|
|
614
622
|
const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
|
|
615
623
|
// Heavy, but not enough on its own to hide a message: mail forwarded through a list
|
|
@@ -628,14 +636,14 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
628
636
|
const free = freeProviders()
|
|
629
637
|
const freeReply = replyDomains.find(entry => free.has(entry))
|
|
630
638
|
if (freeReply && fromDomain && !free.has(fromDomain)) {
|
|
631
|
-
add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}
|
|
639
|
+
add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`, true)
|
|
632
640
|
} else if (replyDomains.length) {
|
|
633
641
|
add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
|
|
634
642
|
}
|
|
635
643
|
|
|
636
644
|
const tld = fromDomain.split('.').pop() ?? ''
|
|
637
|
-
if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable
|
|
638
|
-
if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits')
|
|
645
|
+
if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`, true)
|
|
646
|
+
if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits', true)
|
|
639
647
|
|
|
640
648
|
const subject = (signals.subject ?? '').trim()
|
|
641
649
|
const letters = subject.replace(/[^A-Za-z]/g, '')
|
|
@@ -643,7 +651,7 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
643
651
|
|
|
644
652
|
const body = (signals.text ?? '').toLowerCase()
|
|
645
653
|
const hits = scamPhrases().filter(phrase => body.includes(phrase))
|
|
646
|
-
if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})
|
|
654
|
+
if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`, true)
|
|
647
655
|
else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
|
|
648
656
|
|
|
649
657
|
// Never heard from before is not suspicious by itself — everyone writes once for the
|
|
@@ -660,7 +668,7 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
660
668
|
// time teaches the reader to ignore the warning.
|
|
661
669
|
if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
|
|
662
670
|
|
|
663
|
-
const quarantine = score >= limit
|
|
671
|
+
const quarantine = score >= limit && telling > 0
|
|
664
672
|
return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
|
|
665
673
|
}
|
|
666
674
|
|
|
@@ -1103,7 +1111,7 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
1103
1111
|
const head = latest[0]
|
|
1104
1112
|
await sql`
|
|
1105
1113
|
INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
|
|
1106
|
-
unread_count, starred_count, inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, addressed)
|
|
1114
|
+
unread_count, starred_count, inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, addressed, risk)
|
|
1107
1115
|
VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
|
|
1108
1116
|
${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
|
|
1109
1117
|
${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
|
|
@@ -1361,7 +1369,12 @@ export async function rejudgeStored(options: { before?: string; limit?: number }
|
|
|
1361
1369
|
// they put it — back-fill may label it, never move it out from under them.
|
|
1362
1370
|
const untouched = !Number(row.read) && !Number(row.starred) && !Number(row.archived) && !Number(row.trashed)
|
|
1363
1371
|
const quarantine = verdict.quarantine && untouched
|
|
1364
|
-
if (String(row.risk ?? 'clean') === verdict.risk && Boolean(Number(row.spam)) === quarantine)
|
|
1372
|
+
if (String(row.risk ?? 'clean') === verdict.risk && Boolean(Number(row.spam)) === quarantine) {
|
|
1373
|
+
// The verdict is unchanged, but the conversation summary the list reads from may still
|
|
1374
|
+
// predate it — worth one refresh for the few that carry a warning.
|
|
1375
|
+
if (verdict.risk !== 'clean') await rethreadAfterChange(String(row.id))
|
|
1376
|
+
continue
|
|
1377
|
+
}
|
|
1365
1378
|
|
|
1366
1379
|
await sql`
|
|
1367
1380
|
UPDATE mail_inbox
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const sourceFiles = (dir: string): string[] =>
|
|
6
|
+
readdirSync(dir).flatMap(entry => {
|
|
7
|
+
const path = join(dir, entry)
|
|
8
|
+
if (entry === 'node_modules' || entry === '.next') return []
|
|
9
|
+
if (statSync(path).isDirectory()) return sourceFiles(path)
|
|
10
|
+
return path.endsWith('.ts') || path.endsWith('.tsx') ? [path] : []
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const valueCount = (values: string): number => {
|
|
14
|
+
let depth = 0
|
|
15
|
+
let count = 1
|
|
16
|
+
for (const character of values) {
|
|
17
|
+
if ('([{'.includes(character)) depth += 1
|
|
18
|
+
else if (')]}'.includes(character)) depth -= 1
|
|
19
|
+
else if (character === ',' && depth === 0) count += 1
|
|
20
|
+
}
|
|
21
|
+
return count
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const closingIndex = (text: string): number => {
|
|
25
|
+
let depth = 1
|
|
26
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
27
|
+
if (text[index] === '(') depth += 1
|
|
28
|
+
else if (text[index] === ')') { depth -= 1; if (!depth) return index }
|
|
29
|
+
}
|
|
30
|
+
return -1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let checked = 0
|
|
34
|
+
for (const path of [...sourceFiles('lib'), ...sourceFiles('app')]) {
|
|
35
|
+
const text = readFileSync(path, 'utf8')
|
|
36
|
+
const pattern = /INSERT\s+(?:OR\s+\w+\s+)?INTO\s+(\w+)\s*\(([^)]*)\)\s*\n?\s*VALUES\s*\(/gi
|
|
37
|
+
for (const match of text.matchAll(pattern)) {
|
|
38
|
+
const [table, columns] = [match[1], match[2]]
|
|
39
|
+
const tail = text.slice(match.index + match[0].length)
|
|
40
|
+
const end = closingIndex(tail)
|
|
41
|
+
if (end < 0) continue
|
|
42
|
+
const expected = columns.split(',').filter(column => column.trim()).length
|
|
43
|
+
const actual = valueCount(tail.slice(0, end))
|
|
44
|
+
assert.equal(actual, expected, `${path}: INSERT INTO ${table} lists ${expected} columns but ${actual} values`)
|
|
45
|
+
checked += 1
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assert.ok(checked > 20, `expected to check many inserts, checked ${checked}`)
|
|
50
|
+
console.log(`ok - ${checked} inserts balance their column and value lists`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.19",
|
|
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",
|