@novacraft-engineering/mailbox 0.4.5 → 0.4.17
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/README.md +17 -0
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +35 -3
- package/app/api/mail/threads/route.ts +1 -1
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +195 -1
- package/app/mail/page.tsx +274 -35
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/lib/dev-auth.ts +25 -9
- package/lib/mail-provider.ts +22 -2
- package/lib/mailbox.ts +514 -42
- package/lib/receive.test.ts +20 -8
- package/lib/receive.ts +109 -12
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/lib/mailbox.ts
CHANGED
|
@@ -23,6 +23,13 @@ export type InboundEmail = {
|
|
|
23
23
|
from: string
|
|
24
24
|
to: string[]
|
|
25
25
|
cc: string[]
|
|
26
|
+
/** Written to, copied in, or neither — from the holding mailbox's point of view. */
|
|
27
|
+
addressed?: Addressed
|
|
28
|
+
/** What the spam, virus and sender-authentication checks said. */
|
|
29
|
+
risk?: Risk
|
|
30
|
+
riskReasons?: string[]
|
|
31
|
+
/** Held out of the inbox entirely, rather than only labelled. */
|
|
32
|
+
spam?: boolean
|
|
26
33
|
bcc: string[]
|
|
27
34
|
replyTo: string[]
|
|
28
35
|
subject: string
|
|
@@ -63,7 +70,7 @@ const MAX_EVENTS = 150
|
|
|
63
70
|
/** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
|
|
64
71
|
const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
|
|
65
72
|
|
|
66
|
-
function db() {
|
|
73
|
+
export function db() {
|
|
67
74
|
return tursoConfigured() ? turso() : d1()
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -285,11 +292,42 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
285
292
|
// Folder counts walk the whole mailbox index and Turso meters every entry; an
|
|
286
293
|
// in-memory cache dies with the instance, and instances churn under polling. One
|
|
287
294
|
// row here outlives them all.
|
|
295
|
+
// What this mailbox has learned about a correspondent by handling their mail.
|
|
296
|
+
// Judgement comes from here first and from fixed rules second, so the same sender
|
|
297
|
+
// can be trusted in one mailbox and refused in another.
|
|
298
|
+
`CREATE TABLE IF NOT EXISTS mail_sender_reputation (
|
|
299
|
+
owner TEXT NOT NULL,
|
|
300
|
+
domain TEXT NOT NULL,
|
|
301
|
+
received INTEGER NOT NULL DEFAULT 0,
|
|
302
|
+
trashed INTEGER NOT NULL DEFAULT 0,
|
|
303
|
+
marked_spam INTEGER NOT NULL DEFAULT 0,
|
|
304
|
+
replied INTEGER NOT NULL DEFAULT 0,
|
|
305
|
+
first_seen TEXT,
|
|
306
|
+
last_seen TEXT,
|
|
307
|
+
PRIMARY KEY (owner, domain)
|
|
308
|
+
)`,
|
|
288
309
|
`CREATE TABLE IF NOT EXISTS mail_counts_cache (
|
|
289
310
|
owner TEXT PRIMARY KEY,
|
|
290
311
|
computed_at TEXT NOT NULL,
|
|
291
312
|
counts TEXT NOT NULL
|
|
292
313
|
)`,
|
|
314
|
+
// Sending later is ours to keep, not the provider's. SES has no notion of it and
|
|
315
|
+
// dropped the instruction silently; Brevo refuses outright; a Resend-shaped host
|
|
316
|
+
// may or may not honour it. Parking the prepared message here means the delay
|
|
317
|
+
// behaves the same whoever carries the mail in the end.
|
|
318
|
+
`CREATE TABLE IF NOT EXISTS mail_scheduled (
|
|
319
|
+
id TEXT PRIMARY KEY,
|
|
320
|
+
owner TEXT NOT NULL,
|
|
321
|
+
send_after TEXT NOT NULL,
|
|
322
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
323
|
+
payload TEXT NOT NULL,
|
|
324
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
325
|
+
last_error TEXT,
|
|
326
|
+
sent_id TEXT,
|
|
327
|
+
created_at TEXT NOT NULL
|
|
328
|
+
)`,
|
|
329
|
+
`CREATE INDEX IF NOT EXISTS mail_scheduled_due_idx ON mail_scheduled (status, send_after)`,
|
|
330
|
+
`CREATE INDEX IF NOT EXISTS mail_scheduled_owner_idx ON mail_scheduled (lower(owner), send_after)`,
|
|
293
331
|
// Full-text search belongs in the database, not the browser. The client used to
|
|
294
332
|
// pull the mailbox down and filter it in JS, which cannot hold once the archive
|
|
295
333
|
// runs to six figures.
|
|
@@ -342,6 +380,23 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
342
380
|
// allowed to fail: the second time round the column is already there.
|
|
343
381
|
await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN password_is_default INTEGER NOT NULL DEFAULT 0')
|
|
344
382
|
.catch(() => {})
|
|
383
|
+
// Whether the mailbox was actually written to, or only copied. Stored rather than
|
|
384
|
+
// worked out per query: matching an address inside the cc JSON means a scan, and a
|
|
385
|
+
// mailbox here holds six figures of mail.
|
|
386
|
+
await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN addressed TEXT").catch(() => {})
|
|
387
|
+
// What the scanners and the sender's own domain said about this message.
|
|
388
|
+
await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN risk TEXT").catch(() => {})
|
|
389
|
+
// High-confidence spam is held out of the inbox rather than merely labelled.
|
|
390
|
+
await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN spam INTEGER NOT NULL DEFAULT 0").catch(() => {})
|
|
391
|
+
await sqlRaw("CREATE INDEX IF NOT EXISTS mail_inbox_spam_idx ON mail_inbox (lower(owner), spam, received_at DESC)").catch(() => {})
|
|
392
|
+
await sqlRaw("ALTER TABLE mail_inbox ADD COLUMN risk_reasons TEXT").catch(() => {})
|
|
393
|
+
// The row in the list follows its newest message, so the conversation carries it too.
|
|
394
|
+
await sqlRaw("ALTER TABLE mail_threads ADD COLUMN addressed TEXT").catch(() => {})
|
|
395
|
+
// Worst verdict in the conversation, so a warning cannot hide behind a later reply.
|
|
396
|
+
await sqlRaw("ALTER TABLE mail_threads ADD COLUMN risk TEXT").catch(() => {})
|
|
397
|
+
await sqlRaw("ALTER TABLE mail_threads ADD COLUMN spam_count INTEGER NOT NULL DEFAULT 0").catch(() => {})
|
|
398
|
+
await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_addressed_idx ON mail_inbox (lower(owner), addressed, received_at DESC)')
|
|
399
|
+
.catch(() => {})
|
|
345
400
|
// Where a reset link goes when the account's own mailbox is the thing locked.
|
|
346
401
|
await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_email TEXT').catch(() => {})
|
|
347
402
|
await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_verified INTEGER NOT NULL DEFAULT 0').catch(() => {})
|
|
@@ -402,12 +457,255 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
402
457
|
}
|
|
403
458
|
|
|
404
459
|
// ── Inbox ──────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
export type SenderStanding = {
|
|
462
|
+
received: number
|
|
463
|
+
trashed: number
|
|
464
|
+
markedSpam: number
|
|
465
|
+
replied: number
|
|
466
|
+
firstSeen: string | null
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** What this mailbox has done with this sender's domain before. */
|
|
470
|
+
export async function senderStanding(owner: string | null, domain: string): Promise<SenderStanding> {
|
|
471
|
+
const empty = { received: 0, trashed: 0, markedSpam: 0, replied: 0, firstSeen: null }
|
|
472
|
+
if (!owner || !domain) return empty
|
|
473
|
+
await ensureMailSchema()
|
|
474
|
+
const rows = await db()`
|
|
475
|
+
SELECT received, trashed, marked_spam, replied, first_seen FROM mail_sender_reputation
|
|
476
|
+
WHERE owner = ${owner.toLowerCase()} AND domain = ${domain.toLowerCase()}`
|
|
477
|
+
const row = rows[0]
|
|
478
|
+
if (!row) return empty
|
|
479
|
+
return {
|
|
480
|
+
received: Number(row.received ?? 0),
|
|
481
|
+
trashed: Number(row.trashed ?? 0),
|
|
482
|
+
markedSpam: Number(row.marked_spam ?? 0),
|
|
483
|
+
replied: Number(row.replied ?? 0),
|
|
484
|
+
firstSeen: row.first_seen == null ? null : String(row.first_seen),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Records one more thing this mailbox did with a sender. Every judgement feeds the next. */
|
|
489
|
+
export async function noteSender(
|
|
490
|
+
owner: string | null,
|
|
491
|
+
domain: string,
|
|
492
|
+
what: 'received' | 'trashed' | 'marked_spam' | 'replied',
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
if (!owner || !domain) return
|
|
495
|
+
await ensureMailSchema()
|
|
496
|
+
const now = nowIso()
|
|
497
|
+
const sql = db()
|
|
498
|
+
await sql`
|
|
499
|
+
INSERT INTO mail_sender_reputation (owner, domain, received, trashed, marked_spam, replied, first_seen, last_seen)
|
|
500
|
+
VALUES (${owner.toLowerCase()}, ${domain.toLowerCase()},
|
|
501
|
+
${what === 'received' ? 1 : 0}, ${what === 'trashed' ? 1 : 0},
|
|
502
|
+
${what === 'marked_spam' ? 1 : 0}, ${what === 'replied' ? 1 : 0}, ${now}, ${now})
|
|
503
|
+
ON CONFLICT (owner, domain) DO UPDATE SET
|
|
504
|
+
received = mail_sender_reputation.received + ${what === 'received' ? 1 : 0},
|
|
505
|
+
trashed = mail_sender_reputation.trashed + ${what === 'trashed' ? 1 : 0},
|
|
506
|
+
marked_spam = mail_sender_reputation.marked_spam + ${what === 'marked_spam' ? 1 : 0},
|
|
507
|
+
replied = mail_sender_reputation.replied + ${what === 'replied' ? 1 : 0},
|
|
508
|
+
last_seen = ${now}`
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** What the scanners and the sender's own domain said. */
|
|
512
|
+
export type Risk = 'clean' | 'suspicious' | 'spam' | 'virus'
|
|
513
|
+
|
|
514
|
+
export type RiskSignals = {
|
|
515
|
+
spam?: string | null
|
|
516
|
+
virus?: string | null
|
|
517
|
+
spf?: string | null
|
|
518
|
+
dkim?: string | null
|
|
519
|
+
dmarc?: string | null
|
|
520
|
+
/** The message itself, for the tells authentication cannot see. */
|
|
521
|
+
from?: string | null
|
|
522
|
+
replyTo?: string[] | null
|
|
523
|
+
subject?: string | null
|
|
524
|
+
text?: string | null
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Defaults only. Each is overridable per deployment, so a list can change without a
|
|
528
|
+
* release — metroperil can drop a word its own trade uses every day. */
|
|
529
|
+
const FREE_MAIL_DEFAULT = new Set([
|
|
530
|
+
'gmail.com', 'googlemail.com', 'yahoo.com', 'ymail.com', 'hotmail.com', 'outlook.com',
|
|
531
|
+
'live.com', 'aol.com', 'protonmail.com', 'proton.me', 'mail.com', 'gmx.com', 'yandex.com',
|
|
532
|
+
'icloud.com', 'zoho.com', 'inbox.lv', 'consultant.com', 'qq.com', '163.com',
|
|
533
|
+
])
|
|
534
|
+
|
|
535
|
+
const THROWAWAY_TLDS_DEFAULT = new Set([
|
|
536
|
+
'xyz', 'top', 'buzz', 'click', 'link', 'work', 'gq', 'cf', 'ml', 'tk', 'ga',
|
|
537
|
+
'loan', 'men', 'date', 'racing', 'win', 'stream', 'download', 'review', 'country', 'kim',
|
|
538
|
+
])
|
|
539
|
+
|
|
540
|
+
/** The shape of an advance-fee approach. Counted, never single-word: one alone is innocent. */
|
|
541
|
+
const SCAM_PHRASES_DEFAULT = [
|
|
542
|
+
'next of kin', 'sole beneficiary', 'beneficiary', 'late client', 'deceased client',
|
|
543
|
+
'unclaimed', 'inheritance', 'died without', 'without a will', 'fund transfer',
|
|
544
|
+
'business proposal', 'strictly confidential', 'bank draft', 'consignment',
|
|
545
|
+
'compensation fund', 'lottery', 'winning notification', 'atm card', 'western union',
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
/** The registrable domain behind an address, for reputation to be keyed on. */
|
|
549
|
+
export const senderDomainOf = (address: string): string => registrable(domainOf(address))
|
|
550
|
+
|
|
551
|
+
const domainOf = (address: string): string => {
|
|
552
|
+
const angled = address.match(/<([^>]+)>/)
|
|
553
|
+
const bare = (angled ? angled[1] : address).trim().toLowerCase()
|
|
554
|
+
return bare.split('@').pop() ?? ''
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** example.co.uk and example.com both reduce to the name somebody actually registered. */
|
|
558
|
+
const registrable = (host: string): string => {
|
|
559
|
+
const parts = host.split('.').filter(Boolean)
|
|
560
|
+
if (parts.length <= 2) return parts.join('.')
|
|
561
|
+
const twoLevel = /^(co|com|org|net|gov|ac|edu|ltd|plc)\.[a-z]{2}$/.test(parts.slice(-2).join('.'))
|
|
562
|
+
return parts.slice(twoLevel ? -3 : -2).join('.')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const failed = (verdict: string | null | undefined): boolean =>
|
|
566
|
+
typeof verdict === 'string' && /^(fail|softfail|permerror)$/i.test(verdict.trim())
|
|
567
|
+
|
|
568
|
+
const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<string> => {
|
|
569
|
+
const parsed = (raw ?? '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
|
|
570
|
+
return parsed.length ? new Set(parsed) : new Set(fallback)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Read per call, so a deployment can change any of them without a release.
|
|
574
|
+
const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
|
|
575
|
+
const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
|
|
576
|
+
const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
|
|
577
|
+
|
|
578
|
+
/** Weight at which a message stops being labelled and is held out of the inbox instead. */
|
|
579
|
+
const quarantineAt = () => Number(process.env.MAIL_SPAM_THRESHOLD ?? 6)
|
|
580
|
+
|
|
581
|
+
/** Below this nothing is said at all. One small oddity is not a case. */
|
|
582
|
+
const flagAt = () => Number(process.env.MAIL_SUSPICION_THRESHOLD ?? 3)
|
|
583
|
+
|
|
584
|
+
export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quarantine: boolean }
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* What this mailbox knows, then what is true of the message. The standing a sender has
|
|
588
|
+
* built here leads: somebody you have written back to is not spam because their subject
|
|
589
|
+
* shouts, and somebody whose mail you have binned repeatedly does not get the benefit of
|
|
590
|
+
* the doubt again. The fixed rules only decide the cases with no history to go on, and
|
|
591
|
+
* every one of their lists can be changed per deployment without a release.
|
|
592
|
+
*/
|
|
593
|
+
export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
|
|
594
|
+
const reasons: string[] = []
|
|
595
|
+
let score = 0
|
|
596
|
+
const add = (weight: number, why: string) => { score += weight; reasons.push(why) }
|
|
597
|
+
|
|
598
|
+
if (/^fail$/i.test((signals.virus ?? '').trim())) {
|
|
599
|
+
return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Trust is earned by being written back to, never by volume alone: a sender whose mail
|
|
603
|
+
// arrives forty times and is binned every time has not earned anything.
|
|
604
|
+
const trusted = standing.replied > 0 && standing.markedSpam === 0
|
|
605
|
+
if (standing.markedSpam > 0) {
|
|
606
|
+
add(4 + Math.min(standing.markedSpam, 4),
|
|
607
|
+
`You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`)
|
|
608
|
+
} else if (standing.trashed >= 3 && standing.replied === 0) {
|
|
609
|
+
add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message')
|
|
613
|
+
|
|
614
|
+
const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
|
|
615
|
+
// Heavy, but not enough on its own to hide a message: mail forwarded through a list
|
|
616
|
+
// breaks alignment and fails DMARC while being perfectly legitimate. It warns loudly;
|
|
617
|
+
// it takes a second finding to put a message out of sight.
|
|
618
|
+
if (failed(signals.dmarc)) add(4, 'The sending domain says this message is not from them (DMARC failed)')
|
|
619
|
+
else if (!authenticated) {
|
|
620
|
+
if (failed(signals.spf)) add(2, 'The sending server is not authorised by that domain (SPF failed)')
|
|
621
|
+
if (failed(signals.dkim)) add(2, 'The signature does not match the sending domain (DKIM failed)')
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const fromDomain = registrable(domainOf(signals.from ?? ''))
|
|
625
|
+
const replyDomains = (signals.replyTo ?? [])
|
|
626
|
+
.map(entry => registrable(domainOf(entry)))
|
|
627
|
+
.filter(entry => entry && entry !== fromDomain)
|
|
628
|
+
const free = freeProviders()
|
|
629
|
+
const freeReply = replyDomains.find(entry => free.has(entry))
|
|
630
|
+
if (freeReply && fromDomain && !free.has(fromDomain)) {
|
|
631
|
+
add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`)
|
|
632
|
+
} else if (replyDomains.length) {
|
|
633
|
+
add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
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')
|
|
639
|
+
|
|
640
|
+
const subject = (signals.subject ?? '').trim()
|
|
641
|
+
const letters = subject.replace(/[^A-Za-z]/g, '')
|
|
642
|
+
if (letters.length >= 12 && letters === letters.toUpperCase()) add(1, 'The subject is written entirely in capitals')
|
|
643
|
+
|
|
644
|
+
const body = (signals.text ?? '').toLowerCase()
|
|
645
|
+
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(', ')})`)
|
|
647
|
+
else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
|
|
648
|
+
|
|
649
|
+
// Never heard from before is not suspicious by itself — everyone writes once for the
|
|
650
|
+
// first time — but it is what turns a couple of small oddities into a pattern.
|
|
651
|
+
if (!trusted && standing.received <= 1 && score > 0) add(1, 'This is the first message from this sender')
|
|
652
|
+
|
|
653
|
+
// Someone this mailbox corresponds with is forgiven the small stuff; only findings heavy
|
|
654
|
+
// enough to stand on their own still count against them.
|
|
655
|
+
const limit = quarantineAt()
|
|
656
|
+
if (trusted && score < limit) return { risk: 'clean', reasons: [], score: 0, quarantine: false }
|
|
657
|
+
|
|
658
|
+
// One small oddity is not a case to answer. A subject in capitals from somebody writing
|
|
659
|
+
// for the first time is a stranger in a hurry, not a scam, and saying otherwise every
|
|
660
|
+
// time teaches the reader to ignore the warning.
|
|
661
|
+
if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
|
|
662
|
+
|
|
663
|
+
const quarantine = score >= limit
|
|
664
|
+
return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
/** How the mailbox came to hold a message, from that mailbox's own point of view. */
|
|
669
|
+
export type Addressed = 'direct' | 'copied' | 'other'
|
|
670
|
+
|
|
671
|
+
const bare = (raw: string): string => {
|
|
672
|
+
const angled = raw.match(/<([^>]+)>/)
|
|
673
|
+
return (angled ? angled[1] : raw).trim().toLowerCase()
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Written to, copied in, or neither. The third case is real and common — a blind copy, a
|
|
678
|
+
* distribution list, an alias, or mail caught by the shared address — and calling it a
|
|
679
|
+
* copy would be a guess dressed as a fact, so it gets its own answer.
|
|
680
|
+
*
|
|
681
|
+
* Judged against the mailbox that holds the message, never the person reading it: an
|
|
682
|
+
* administrator reading everyone's mail must not be told they were copied on someone
|
|
683
|
+
* else's.
|
|
684
|
+
*/
|
|
685
|
+
export function classifyAddressed(
|
|
686
|
+
owner: string | null | undefined,
|
|
687
|
+
to: string[],
|
|
688
|
+
cc: string[],
|
|
689
|
+
): Addressed {
|
|
690
|
+
const seat = (owner ?? '').trim().toLowerCase()
|
|
691
|
+
if (!seat) return 'other'
|
|
692
|
+
if (to.some(entry => bare(entry) === seat)) return 'direct'
|
|
693
|
+
if (cc.some(entry => bare(entry) === seat)) return 'copied'
|
|
694
|
+
return 'other'
|
|
695
|
+
}
|
|
696
|
+
|
|
405
697
|
function mapInbound(row: Record<string, unknown>): InboundEmail {
|
|
406
698
|
return {
|
|
407
699
|
id: String(row.id),
|
|
408
700
|
from: (row.from_addr as string) ?? '',
|
|
409
701
|
to: parseArray(row.to_addrs),
|
|
410
702
|
cc: parseArray(row.cc),
|
|
703
|
+
addressed: (['direct', 'copied', 'other'].includes(String(row.addressed))
|
|
704
|
+
? String(row.addressed)
|
|
705
|
+
: classifyAddressed(row.owner == null ? null : String(row.owner), parseArray(row.to_addrs), parseArray(row.cc))) as Addressed,
|
|
706
|
+
risk: (['clean', 'suspicious', 'spam', 'virus'].includes(String(row.risk)) ? String(row.risk) : 'clean') as Risk,
|
|
707
|
+
riskReasons: parseJson<string[]>(row.risk_reasons, []),
|
|
708
|
+
spam: Boolean(row.spam),
|
|
411
709
|
bcc: parseArray(row.bcc),
|
|
412
710
|
replyTo: parseArray(row.reply_to),
|
|
413
711
|
subject: (row.subject as string) ?? '',
|
|
@@ -449,13 +747,15 @@ export type InboxPage = {
|
|
|
449
747
|
export async function searchInbox(options: {
|
|
450
748
|
text?: string
|
|
451
749
|
owner?: string
|
|
452
|
-
folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
750
|
+
folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
|
|
453
751
|
unread?: boolean
|
|
454
752
|
starred?: boolean
|
|
455
753
|
hasAttachment?: boolean
|
|
456
754
|
label?: string
|
|
457
755
|
from?: string
|
|
458
756
|
to?: string
|
|
757
|
+
/** direct | copied | other, or 'not-copied' to leave copies out. */
|
|
758
|
+
addressed?: string
|
|
459
759
|
limit?: number
|
|
460
760
|
offset?: number
|
|
461
761
|
cursor?: string | null
|
|
@@ -490,16 +790,23 @@ export async function searchInbox(options: {
|
|
|
490
790
|
if (options.label) { where.push('m.labels LIKE ?'); args.push(`%${options.label}%`) }
|
|
491
791
|
if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
|
|
492
792
|
if (options.to) { where.push('lower(m.to_addrs) LIKE ?'); args.push(`%${options.to.toLowerCase()}%`) }
|
|
793
|
+
// Filtered in the query, not in the browser: a client-side pass would only ever narrow
|
|
794
|
+
// the page already loaded, which on a mailbox of six figures reads as a broken filter.
|
|
795
|
+
if (options.addressed === 'not-copied') where.push("COALESCE(m.addressed, 'other') <> 'copied'")
|
|
796
|
+
else if (options.addressed) { where.push("COALESCE(m.addressed, 'other') = ?"); args.push(options.addressed) }
|
|
493
797
|
|
|
494
798
|
// A snoozed message is only out of the inbox while its time is still ahead; the clause
|
|
495
799
|
// does the waking, so nothing has to run on a timer.
|
|
496
800
|
const nowIso = new Date().toISOString()
|
|
497
801
|
const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
else if (options.folder === '
|
|
502
|
-
else if (options.folder === '
|
|
802
|
+
// Quarantined mail belongs to exactly one folder and appears in no other, or holding it
|
|
803
|
+
// back would be pointless — it would still be sitting in the inbox under a label.
|
|
804
|
+
if (options.folder === 'spam') where.push('m.spam = 1 AND m.trashed = 0')
|
|
805
|
+
else if (options.folder === 'trash') where.push('m.trashed = 1')
|
|
806
|
+
else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0 AND m.spam = 0')
|
|
807
|
+
else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0 AND m.spam = 0')
|
|
808
|
+
else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0 AND m.spam = 0'); args.push(nowIso) }
|
|
809
|
+
else if (options.folder === 'inbox') { where.push(`m.archived = 0 AND m.trashed = 0 AND m.spam = 0 AND ${awake}`); args.push(nowIso) }
|
|
503
810
|
|
|
504
811
|
const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
|
505
812
|
|
|
@@ -529,7 +836,8 @@ export async function searchInbox(options: {
|
|
|
529
836
|
'contentType', json_extract(value, '$.contentType'),
|
|
530
837
|
'size', json_extract(value, '$.size')))
|
|
531
838
|
FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
|
|
532
|
-
m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id
|
|
839
|
+
m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id,
|
|
840
|
+
m.addressed, m.risk, m.risk_reasons, m.spam
|
|
533
841
|
FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
|
|
534
842
|
ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
|
|
535
843
|
cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
|
|
@@ -575,6 +883,7 @@ export type FolderTally = {
|
|
|
575
883
|
starred: number
|
|
576
884
|
archived: number
|
|
577
885
|
trashed: number
|
|
886
|
+
spam: number
|
|
578
887
|
snoozed: number
|
|
579
888
|
}
|
|
580
889
|
|
|
@@ -596,9 +905,13 @@ const COUNTS_CACHE_MS = 5 * 60 * 1000
|
|
|
596
905
|
|
|
597
906
|
/** countFolders through a durable cache: one row read when fresh, a full scan only when stale. */
|
|
598
907
|
export async function invalidateCounts(owner: string | null | undefined): Promise<void> {
|
|
599
|
-
if (!owner) return
|
|
600
908
|
try {
|
|
601
|
-
|
|
909
|
+
const sql = db()
|
|
910
|
+
// The '*all' row sums every mailbox, so one person's mail moving makes it wrong too.
|
|
911
|
+
// Dropping only the owner's row left the all-inboxes view quoting figures from before
|
|
912
|
+
// the delete, and an owner of null dropped nothing at all.
|
|
913
|
+
if (owner) await sql`DELETE FROM mail_counts_cache WHERE owner = ${owner.toLowerCase()}`
|
|
914
|
+
await sql`DELETE FROM mail_counts_cache WHERE owner = '*all'`
|
|
602
915
|
} catch {
|
|
603
916
|
}
|
|
604
917
|
}
|
|
@@ -635,12 +948,13 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
635
948
|
const rows = await tagged(
|
|
636
949
|
sql,
|
|
637
950
|
`SELECT
|
|
638
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
|
|
639
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 AND read = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
|
|
640
|
-
SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
|
|
641
|
-
SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
|
|
951
|
+
SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
|
|
952
|
+
SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 AND read = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
|
|
953
|
+
SUM(CASE WHEN starred = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS starred,
|
|
954
|
+
SUM(CASE WHEN archived = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS archived,
|
|
642
955
|
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
643
|
-
SUM(CASE WHEN
|
|
956
|
+
SUM(CASE WHEN spam = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS spam,
|
|
957
|
+
SUM(CASE WHEN snoozed_until > ? AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS snoozed
|
|
644
958
|
FROM mail_inbox ${scope}`,
|
|
645
959
|
[nowIso, nowIso, nowIso, ...args],
|
|
646
960
|
)
|
|
@@ -661,6 +975,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
661
975
|
SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
|
|
662
976
|
SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
|
|
663
977
|
SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
|
|
978
|
+
0 AS spam,
|
|
664
979
|
SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
|
|
665
980
|
FROM mail_threads WHERE owner = ?`,
|
|
666
981
|
[nowIso, nowIso, nowIso, owner.toLowerCase()],
|
|
@@ -673,6 +988,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
673
988
|
starred: threadValue('starred'),
|
|
674
989
|
archived: threadValue('archived'),
|
|
675
990
|
trashed: threadValue('trashed'),
|
|
991
|
+
spam: threadValue('spam'),
|
|
676
992
|
snoozed: threadValue('snoozed'),
|
|
677
993
|
}
|
|
678
994
|
}
|
|
@@ -683,6 +999,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
683
999
|
starred: value('starred'),
|
|
684
1000
|
archived: value('archived'),
|
|
685
1001
|
trashed: value('trashed'),
|
|
1002
|
+
spam: value('spam'),
|
|
686
1003
|
snoozed: value('snoozed'),
|
|
687
1004
|
conversations,
|
|
688
1005
|
}
|
|
@@ -694,10 +1011,10 @@ export async function readInbox(filter?: { owner?: string }): Promise<InboundEma
|
|
|
694
1011
|
const owner = filter?.owner?.trim().toLowerCase()
|
|
695
1012
|
const rows = owner
|
|
696
1013
|
? await sql`
|
|
697
|
-
SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id
|
|
1014
|
+
SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id, addressed, risk, risk_reasons, spam
|
|
698
1015
|
FROM mail_inbox WHERE lower(owner) = ${owner} ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
|
|
699
1016
|
: await sql`
|
|
700
|
-
SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id
|
|
1017
|
+
SELECT id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, starred, archived, trashed, labels, owner, thread_id, addressed, risk, risk_reasons, spam
|
|
701
1018
|
FROM mail_inbox ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
|
|
702
1019
|
return rows.map(mapInbound)
|
|
703
1020
|
}
|
|
@@ -715,6 +1032,7 @@ export type ThreadRow = {
|
|
|
715
1032
|
inboxCount: number
|
|
716
1033
|
archivedCount: number
|
|
717
1034
|
trashedCount: number
|
|
1035
|
+
spamCount: number
|
|
718
1036
|
attachCount: number
|
|
719
1037
|
senders: string[]
|
|
720
1038
|
snippet: string
|
|
@@ -753,12 +1071,14 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
753
1071
|
const owner = ownerRaw.toLowerCase()
|
|
754
1072
|
const agg = await sql`
|
|
755
1073
|
SELECT COUNT(*) AS n,
|
|
756
|
-
SUM(CASE WHEN read = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS unread,
|
|
757
|
-
SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
|
|
758
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
|
|
759
|
-
SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
|
|
1074
|
+
SUM(CASE WHEN read = 0 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS unread,
|
|
1075
|
+
SUM(CASE WHEN starred = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS starred,
|
|
1076
|
+
SUM(CASE WHEN archived = 0 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS inbox,
|
|
1077
|
+
SUM(CASE WHEN archived = 1 AND trashed = 0 AND spam = 0 THEN 1 ELSE 0 END) AS archived,
|
|
1078
|
+
SUM(CASE WHEN spam = 1 THEN 1 ELSE 0 END) AS spam,
|
|
760
1079
|
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
761
1080
|
SUM(CASE WHEN attachments IS NOT NULL AND attachments NOT IN ('', '[]') THEN 1 ELSE 0 END) AS attach,
|
|
1081
|
+
MAX(CASE risk WHEN 'virus' THEN 3 WHEN 'spam' THEN 2 WHEN 'suspicious' THEN 1 ELSE 0 END) AS worst_risk,
|
|
762
1082
|
MIN(received_at) AS first_at, MAX(received_at) AS latest_at
|
|
763
1083
|
FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
|
|
764
1084
|
const total = Number(agg[0]?.n ?? 0)
|
|
@@ -767,7 +1087,7 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
767
1087
|
return
|
|
768
1088
|
}
|
|
769
1089
|
const latest = await sql`
|
|
770
|
-
SELECT id, subject, COALESCE(snippet, substr(COALESCE(body_text, ''), 1, 320)) AS snippet
|
|
1090
|
+
SELECT id, subject, addressed, COALESCE(snippet, substr(COALESCE(body_text, ''), 1, 320)) AS snippet
|
|
771
1091
|
FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
|
|
772
1092
|
ORDER BY received_at DESC, id DESC LIMIT 1`
|
|
773
1093
|
const members = await sql`
|
|
@@ -783,19 +1103,22 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
783
1103
|
const head = latest[0]
|
|
784
1104
|
await sql`
|
|
785
1105
|
INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
|
|
786
|
-
unread_count, starred_count, inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels)
|
|
1106
|
+
unread_count, starred_count, inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, addressed)
|
|
787
1107
|
VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
|
|
788
1108
|
${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
|
|
789
1109
|
${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
|
|
790
|
-
${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].attach ?? 0)},
|
|
791
|
-
${JSON.stringify(senders)}, ${String(head?.snippet ?? '')}, ${JSON.stringify([...labels])}
|
|
1110
|
+
${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].spam ?? 0)}, ${Number(agg[0].attach ?? 0)},
|
|
1111
|
+
${JSON.stringify(senders)}, ${String(head?.snippet ?? '')}, ${JSON.stringify([...labels])},
|
|
1112
|
+
${head?.addressed == null ? null : String(head.addressed)},
|
|
1113
|
+
${['clean', 'suspicious', 'spam', 'virus'][Number(agg[0]?.worst_risk ?? 0)] ?? 'clean'})
|
|
792
1114
|
ON CONFLICT (owner, thread_id) DO UPDATE SET
|
|
793
1115
|
subject_key = excluded.subject_key, subject = excluded.subject, first_at = excluded.first_at,
|
|
794
1116
|
latest_at = excluded.latest_at, latest_id = excluded.latest_id, count = excluded.count,
|
|
795
1117
|
unread_count = excluded.unread_count, starred_count = excluded.starred_count,
|
|
796
1118
|
inbox_count = excluded.inbox_count, archived_count = excluded.archived_count,
|
|
797
|
-
trashed_count = excluded.trashed_count, attach_count = excluded.attach_count,
|
|
798
|
-
senders = excluded.senders, snippet = excluded.snippet, labels = excluded.labels
|
|
1119
|
+
trashed_count = excluded.trashed_count, spam_count = excluded.spam_count, attach_count = excluded.attach_count,
|
|
1120
|
+
senders = excluded.senders, snippet = excluded.snippet, labels = excluded.labels,
|
|
1121
|
+
addressed = excluded.addressed, risk = excluded.risk`
|
|
799
1122
|
}
|
|
800
1123
|
|
|
801
1124
|
/** Thread the message and refresh its summary; never lets a threading fault fail a write. */
|
|
@@ -836,7 +1159,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
|
|
|
836
1159
|
}
|
|
837
1160
|
}
|
|
838
1161
|
|
|
839
|
-
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
1162
|
+
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
|
|
840
1163
|
|
|
841
1164
|
/** The newest conversations in a folder: one row each, already summarised. */
|
|
842
1165
|
export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
|
|
@@ -846,7 +1169,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
846
1169
|
const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
|
|
847
1170
|
const nowIso = new Date().toISOString()
|
|
848
1171
|
const predicate =
|
|
849
|
-
folder === '
|
|
1172
|
+
folder === 'spam' ? 'spam_count > 0'
|
|
1173
|
+
: folder === 'archive' ? 'archived_count > 0'
|
|
850
1174
|
: folder === 'trash' ? 'trashed_count > 0'
|
|
851
1175
|
: folder === 'starred' ? 'starred_count > 0'
|
|
852
1176
|
: folder === 'snoozed' ? 'snoozed_until > ?'
|
|
@@ -866,8 +1190,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
866
1190
|
SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
|
|
867
1191
|
SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
|
|
868
1192
|
SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
|
|
869
|
-
SUM(trashed_count) AS trashed_count, SUM(attach_count) AS attach_count,
|
|
870
|
-
senders, snippet, labels, snoozed_until
|
|
1193
|
+
SUM(trashed_count) AS trashed_count, SUM(spam_count) AS spam_count, SUM(attach_count) AS attach_count,
|
|
1194
|
+
senders, snippet, labels, snoozed_until, addressed, risk
|
|
871
1195
|
FROM mail_threads
|
|
872
1196
|
GROUP BY thread_id
|
|
873
1197
|
HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
|
|
@@ -875,7 +1199,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
875
1199
|
[...folderArgs, ...cursorArgs, limit])
|
|
876
1200
|
: await tagged(db(), `
|
|
877
1201
|
SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
|
|
878
|
-
inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
|
|
1202
|
+
inbox_count, archived_count, trashed_count, spam_count, attach_count, senders, snippet, labels, snoozed_until, addressed, risk
|
|
879
1203
|
FROM mail_threads WHERE owner = ? AND ${predicate}
|
|
880
1204
|
${cursorClause ? `AND ${cursorClause}` : ''}
|
|
881
1205
|
ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
|
|
@@ -894,11 +1218,14 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
894
1218
|
inboxCount: Number(row.inbox_count ?? 0),
|
|
895
1219
|
archivedCount: Number(row.archived_count ?? 0),
|
|
896
1220
|
trashedCount: Number(row.trashed_count ?? 0),
|
|
1221
|
+
spamCount: Number(row.spam_count ?? 0),
|
|
897
1222
|
attachCount: Number(row.attach_count ?? 0),
|
|
898
1223
|
senders: parseJson<string[]>(row.senders, []),
|
|
899
1224
|
snippet: String(row.snippet ?? ''),
|
|
900
1225
|
labels: parseJson<string[]>(row.labels, []),
|
|
901
1226
|
snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
|
|
1227
|
+
addressed: (['direct', 'copied', 'other'].includes(String(row.addressed)) ? String(row.addressed) : 'direct') as Addressed,
|
|
1228
|
+
risk: (['clean', 'suspicious', 'spam', 'virus'].includes(String(row.risk)) ? String(row.risk) : 'clean') as Risk,
|
|
902
1229
|
}))
|
|
903
1230
|
return { rows: mapped, nextCursor }
|
|
904
1231
|
}
|
|
@@ -968,13 +1295,107 @@ export async function appendInbound(
|
|
|
968
1295
|
await ensureMailSchema()
|
|
969
1296
|
const sql = db()
|
|
970
1297
|
await sql`
|
|
971
|
-
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta)
|
|
972
|
-
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)})
|
|
1298
|
+
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta, addressed, risk, risk_reasons, spam)
|
|
1299
|
+
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0})
|
|
973
1300
|
ON CONFLICT (id) DO NOTHING`
|
|
974
1301
|
await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
|
|
975
1302
|
await invalidateCounts(email.owner)
|
|
976
1303
|
}
|
|
977
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* Fill in what a hollow row is missing, for messages stored before the body could be
|
|
1307
|
+
* fetched. Only ever writes content that is absent — read, starred, archived, trashed,
|
|
1308
|
+
* labels and owner are the reader's, not the repair's, and a row that already has a body
|
|
1309
|
+
* is left exactly as it is.
|
|
1310
|
+
*/
|
|
1311
|
+
export async function rejudgeStored(options: { before?: string; limit?: number } = {}): Promise<{
|
|
1312
|
+
scanned: number
|
|
1313
|
+
changed: number
|
|
1314
|
+
quarantined: number
|
|
1315
|
+
cursor: string | null
|
|
1316
|
+
}> {
|
|
1317
|
+
await ensureMailSchema()
|
|
1318
|
+
const sql = db()
|
|
1319
|
+
const limit = Math.min(Math.max(options.limit ?? 200, 1), 500)
|
|
1320
|
+
const before = options.before ?? '9999-12-31'
|
|
1321
|
+
const rows = await sql`
|
|
1322
|
+
SELECT id, owner, from_addr, reply_to, subject, body_text, headers, received_at,
|
|
1323
|
+
read, starred, archived, trashed, risk, spam
|
|
1324
|
+
FROM mail_inbox
|
|
1325
|
+
WHERE received_at < ${before}
|
|
1326
|
+
ORDER BY received_at DESC
|
|
1327
|
+
LIMIT ${limit}`
|
|
1328
|
+
|
|
1329
|
+
const result = { scanned: rows.length, changed: 0, quarantined: 0, cursor: null as string | null }
|
|
1330
|
+
if (rows.length === 0) return result
|
|
1331
|
+
result.cursor = String(rows[rows.length - 1].received_at ?? '')
|
|
1332
|
+
|
|
1333
|
+
const standings = new Map<string, SenderStanding>()
|
|
1334
|
+
const touchedOwners = new Set<string>()
|
|
1335
|
+
for (const row of rows) {
|
|
1336
|
+
const owner = String(row.owner ?? '')
|
|
1337
|
+
const senderDomain = senderDomainOf(String(row.from_addr ?? ''))
|
|
1338
|
+
const key = `${owner.toLowerCase()}\u0000${senderDomain}`
|
|
1339
|
+
let standing = standings.get(key)
|
|
1340
|
+
if (!standing) {
|
|
1341
|
+
standing = await senderStanding(owner, senderDomain)
|
|
1342
|
+
standings.set(key, standing)
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const headers = parseJson<Record<string, unknown>>(row.headers, {})
|
|
1346
|
+
const authHeader = headerString(headers, 'authentication-results').toLowerCase()
|
|
1347
|
+
const mechanism = (name: string) => authHeader.match(new RegExp(`${name}=(\\w+)`))?.[1] ?? null
|
|
1348
|
+
const verdict = judgeMessage({
|
|
1349
|
+
spam: null,
|
|
1350
|
+
virus: null,
|
|
1351
|
+
spf: mechanism('spf'),
|
|
1352
|
+
dkim: mechanism('dkim'),
|
|
1353
|
+
dmarc: mechanism('dmarc'),
|
|
1354
|
+
from: String(row.from_addr ?? ''),
|
|
1355
|
+
replyTo: parseJson<string[]>(row.reply_to, []),
|
|
1356
|
+
subject: String(row.subject ?? ''),
|
|
1357
|
+
text: row.body_text == null ? null : String(row.body_text),
|
|
1358
|
+
}, standing)
|
|
1359
|
+
|
|
1360
|
+
// A message the reader has already read, starred, filed or binned stays exactly where
|
|
1361
|
+
// they put it — back-fill may label it, never move it out from under them.
|
|
1362
|
+
const untouched = !Number(row.read) && !Number(row.starred) && !Number(row.archived) && !Number(row.trashed)
|
|
1363
|
+
const quarantine = verdict.quarantine && untouched
|
|
1364
|
+
if (String(row.risk ?? 'clean') === verdict.risk && Boolean(Number(row.spam)) === quarantine) continue
|
|
1365
|
+
|
|
1366
|
+
await sql`
|
|
1367
|
+
UPDATE mail_inbox
|
|
1368
|
+
SET risk = ${verdict.risk}, risk_reasons = ${JSON.stringify(verdict.reasons)}, spam = ${quarantine ? 1 : 0}
|
|
1369
|
+
WHERE id = ${String(row.id)}`
|
|
1370
|
+
await rethreadAfterChange(String(row.id))
|
|
1371
|
+
result.changed += 1
|
|
1372
|
+
if (quarantine) result.quarantined += 1
|
|
1373
|
+
if (owner) touchedOwners.add(owner)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for (const owner of touchedOwners) await invalidateCounts(owner)
|
|
1377
|
+
return result
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
export async function repairInbound(
|
|
1381
|
+
email: Pick<InboundEmail, 'id' | 'html' | 'text' | 'headers' | 'attachments'>,
|
|
1382
|
+
): Promise<boolean> {
|
|
1383
|
+
await ensureMailSchema()
|
|
1384
|
+
const sql = db()
|
|
1385
|
+
const rows = await sql`
|
|
1386
|
+
UPDATE mail_inbox SET
|
|
1387
|
+
html = CASE WHEN coalesce(html, '') = '' THEN ${email.html} ELSE html END,
|
|
1388
|
+
body_text = CASE WHEN coalesce(body_text, '') = '' THEN ${email.text} ELSE body_text END,
|
|
1389
|
+
headers = CASE WHEN coalesce(headers, '') IN ('', '{}') THEN ${JSON.stringify(email.headers)} ELSE headers END,
|
|
1390
|
+
attachments = CASE WHEN coalesce(attachments, '') IN ('', '[]') THEN ${JSON.stringify(email.attachments)} ELSE attachments END,
|
|
1391
|
+
attach_meta = CASE WHEN coalesce(attach_meta, '') IN ('', '[]') THEN ${attachMeta(email.attachments)} ELSE attach_meta END,
|
|
1392
|
+
snippet = CASE WHEN coalesce(snippet, '') = '' THEN ${listSnippet(email.text)} ELSE snippet END,
|
|
1393
|
+
thread_meta = CASE WHEN coalesce(thread_meta, '') IN ('', '{}') THEN ${threadMeta(email.headers)} ELSE thread_meta END
|
|
1394
|
+
WHERE id = ${email.id}
|
|
1395
|
+
RETURNING id`
|
|
1396
|
+
return rows.length > 0
|
|
1397
|
+
}
|
|
1398
|
+
|
|
978
1399
|
export async function getInboundSource(
|
|
979
1400
|
id: string,
|
|
980
1401
|
): Promise<{ owner: string | null; attachments: Array<Record<string, unknown>> } | null> {
|
|
@@ -1222,13 +1643,44 @@ export async function listInboundWithAttachments(owner: string | null): Promise<
|
|
|
1222
1643
|
export async function setInboxOwner(id: string, owner: string | null): Promise<void> {
|
|
1223
1644
|
const sql = db()
|
|
1224
1645
|
const before = await sql`SELECT owner, thread_id FROM mail_inbox WHERE id = ${id}`
|
|
1225
|
-
|
|
1646
|
+
const moved = await sql`SELECT to_addrs, cc FROM mail_inbox WHERE id = ${id}`
|
|
1647
|
+
const nowAddressed = classifyAddressed(owner, parseArray(moved[0]?.to_addrs), parseArray(moved[0]?.cc))
|
|
1648
|
+
// A message that was a copy in one mailbox can be direct mail in another.
|
|
1649
|
+
await sql`UPDATE mail_inbox SET owner = ${owner ? owner.toLowerCase() : null}, thread_id = NULL, addressed = ${nowAddressed} WHERE id = ${id}`
|
|
1650
|
+
// Both sides change: the mailbox it left and the one it arrived in.
|
|
1651
|
+
await invalidateCounts(before[0]?.owner == null ? null : String(before[0].owner))
|
|
1652
|
+
await invalidateCounts(owner)
|
|
1226
1653
|
await rethreadAfterChange(id, before[0]?.owner == null ? null : String(before[0].owner), before[0]?.thread_id == null ? null : String(before[0].thread_id))
|
|
1227
1654
|
}
|
|
1228
1655
|
|
|
1229
1656
|
export async function markInboundRead(id: string): Promise<void> {
|
|
1230
1657
|
const sql = db()
|
|
1231
1658
|
await sql`UPDATE mail_inbox SET read = 1 WHERE id = ${id}`
|
|
1659
|
+
const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
|
|
1660
|
+
await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
|
|
1661
|
+
await rethreadAfterChange(id)
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Moves a message in or out of quarantine and remembers the decision. This is the loop:
|
|
1666
|
+
* what the reader does with a sender's mail decides how the next one is treated, so the
|
|
1667
|
+
* same message can be spam in one mailbox and ordinary correspondence in another.
|
|
1668
|
+
*/
|
|
1669
|
+
export async function setInboundSpam(id: string, spam: boolean): Promise<void> {
|
|
1670
|
+
await ensureMailSchema()
|
|
1671
|
+
const sql = db()
|
|
1672
|
+
const rows = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
|
|
1673
|
+
const row = rows[0]
|
|
1674
|
+
// A reader who says "not spam" has overruled the judgement, so the warning goes with the
|
|
1675
|
+
// quarantine — leaving the badge on would argue with them every time they open it.
|
|
1676
|
+
await (spam
|
|
1677
|
+
? sql`UPDATE mail_inbox SET spam = 1, archived = 0, trashed = 0 WHERE id = ${id}`
|
|
1678
|
+
: sql`UPDATE mail_inbox SET spam = 0, archived = 0, trashed = 0, risk = 'clean', risk_reasons = '[]' WHERE id = ${id}`)
|
|
1679
|
+
if (row?.owner) {
|
|
1680
|
+
await noteSender(String(row.owner), senderDomainOf(String(row.from_addr ?? '')), spam ? 'marked_spam' : 'replied')
|
|
1681
|
+
.catch(() => {})
|
|
1682
|
+
}
|
|
1683
|
+
await invalidateCounts(row?.owner == null ? null : String(row.owner))
|
|
1232
1684
|
await rethreadAfterChange(id)
|
|
1233
1685
|
}
|
|
1234
1686
|
|
|
@@ -1238,7 +1690,12 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
|
|
|
1238
1690
|
if (flags.starred !== undefined) await sql`UPDATE mail_inbox SET starred = ${flags.starred} WHERE id = ${id}`
|
|
1239
1691
|
if (flags.archived !== undefined) await sql`UPDATE mail_inbox SET archived = ${flags.archived} WHERE id = ${id}`
|
|
1240
1692
|
if (flags.trashed !== undefined) await sql`UPDATE mail_inbox SET trashed = ${flags.trashed} WHERE id = ${id}`
|
|
1241
|
-
const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
|
|
1693
|
+
const owned = await sql`SELECT owner, from_addr FROM mail_inbox WHERE id = ${id}`
|
|
1694
|
+
// Binning a sender's mail counts against them; it is the commonest way a reader says
|
|
1695
|
+
// "not this one" without ever pressing a button marked spam.
|
|
1696
|
+
if (flags.trashed === true && owned[0]?.owner) {
|
|
1697
|
+
await noteSender(String(owned[0].owner), senderDomainOf(String(owned[0].from_addr ?? '')), 'trashed').catch(() => {})
|
|
1698
|
+
}
|
|
1242
1699
|
await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
|
|
1243
1700
|
await rethreadAfterChange(id)
|
|
1244
1701
|
}
|
|
@@ -1304,6 +1761,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
|
|
|
1304
1761
|
export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
|
|
1305
1762
|
const sql = db()
|
|
1306
1763
|
await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
|
|
1764
|
+
const owned = await sql`SELECT owner FROM mail_inbox WHERE id = ${id}`
|
|
1765
|
+
await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
|
|
1307
1766
|
}
|
|
1308
1767
|
|
|
1309
1768
|
// ── Sent-mail flags (star / archive / trash on Resend-sent emails) ──
|
|
@@ -1752,6 +2211,11 @@ export async function readSentArchive(options: {
|
|
|
1752
2211
|
// ── Sent-mail attribution (owner + automated flag + thread link) ─
|
|
1753
2212
|
export type SentMeta = { owner: string | null; isAuto: boolean; inReplyTo: string | null }
|
|
1754
2213
|
|
|
2214
|
+
/** Writing back to somebody is the clearest statement that their mail is wanted. */
|
|
2215
|
+
export async function noteReplyTo(owner: string | null, address: string): Promise<void> {
|
|
2216
|
+
await noteSender(owner, senderDomainOf(address), 'replied').catch(() => {})
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1755
2219
|
export async function recordSentMeta(
|
|
1756
2220
|
emailId: string,
|
|
1757
2221
|
owner: string | null,
|
|
@@ -2018,7 +2482,10 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
|
|
|
2018
2482
|
if (!email) return null
|
|
2019
2483
|
await sql`
|
|
2020
2484
|
INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
|
|
2021
|
-
ON CONFLICT (email) DO UPDATE SET
|
|
2485
|
+
ON CONFLICT (email) DO UPDATE SET
|
|
2486
|
+
password_hash = excluded.password_hash,
|
|
2487
|
+
status = 'active',
|
|
2488
|
+
password_is_default = 0`
|
|
2022
2489
|
// Any other link still outstanding for this address is now stale — and one of them
|
|
2023
2490
|
// may be the reason the password is being changed.
|
|
2024
2491
|
await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
|
|
@@ -2172,7 +2639,8 @@ export async function backfillAttachments(limit: number, countRemaining = false)
|
|
|
2172
2639
|
rows.map(async row => {
|
|
2173
2640
|
const id = String(row.id)
|
|
2174
2641
|
try {
|
|
2175
|
-
const
|
|
2642
|
+
const base = (process.env.RESEND_BASE_URL ?? '').trim().replace(/\/+$/, '') || 'https://api.resend.com'
|
|
2643
|
+
const listing = await fetch(`${base}/emails/receiving/${encodeURIComponent(id)}/attachments`, {
|
|
2176
2644
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
2177
2645
|
})
|
|
2178
2646
|
if (!listing.ok) {
|
|
@@ -2195,10 +2663,14 @@ export async function backfillAttachments(limit: number, countRemaining = false)
|
|
|
2195
2663
|
const contentType = entry.content_type ? String(entry.content_type) : undefined
|
|
2196
2664
|
const source = entry.download_url ? String(entry.download_url) : ''
|
|
2197
2665
|
const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0) }
|
|
2198
|
-
if (!source) return meta
|
|
2199
|
-
|
|
2200
|
-
if (
|
|
2201
|
-
|
|
2666
|
+
if (!source && !entry.content) return meta
|
|
2667
|
+
let bytes: Buffer
|
|
2668
|
+
if (entry.content) bytes = Buffer.from(String(entry.content), 'base64')
|
|
2669
|
+
else {
|
|
2670
|
+
const binary = await fetch(source)
|
|
2671
|
+
if (!binary.ok) return meta
|
|
2672
|
+
bytes = Buffer.from(await binary.arrayBuffer())
|
|
2673
|
+
}
|
|
2202
2674
|
const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
|
|
2203
2675
|
const key = `attachments/${id}/${index}-${safeName}`
|
|
2204
2676
|
if (!(await putObject(key, bytes, contentType))) return meta
|