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