@novacraft-engineering/mailbox 0.4.4 → 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/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- 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/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- 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 +39 -7
- package/app/api/mail/threads/route.ts +1 -1
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +254 -1
- package/app/mail/page.tsx +539 -87
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +29 -9
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +39 -3
- package/lib/mailbox.ts +578 -51
- package/lib/receive.test.ts +25 -0
- package/lib/receive.ts +117 -17
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/lib/share-ticket.ts +42 -0
- 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,28 @@ 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(() => {})
|
|
400
|
+
// Where a reset link goes when the account's own mailbox is the thing locked.
|
|
401
|
+
await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_email TEXT').catch(() => {})
|
|
402
|
+
await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_verified INTEGER NOT NULL DEFAULT 0').catch(() => {})
|
|
403
|
+
// A link mailed to an unproven address must not be able to set a password.
|
|
404
|
+
await sqlRaw("ALTER TABLE mail_reset_tokens ADD COLUMN purpose TEXT NOT NULL DEFAULT 'reset'").catch(() => {})
|
|
345
405
|
await sqlRaw("ALTER TABLE mail_webhook_events ADD COLUMN status TEXT NOT NULL DEFAULT 'working'")
|
|
346
406
|
.catch(() => {})
|
|
347
407
|
await sqlRaw('ALTER TABLE mail_sent ADD COLUMN attachments TEXT').catch(() => {})
|
|
@@ -397,12 +457,255 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
397
457
|
}
|
|
398
458
|
|
|
399
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
|
+
|
|
400
697
|
function mapInbound(row: Record<string, unknown>): InboundEmail {
|
|
401
698
|
return {
|
|
402
699
|
id: String(row.id),
|
|
403
700
|
from: (row.from_addr as string) ?? '',
|
|
404
701
|
to: parseArray(row.to_addrs),
|
|
405
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),
|
|
406
709
|
bcc: parseArray(row.bcc),
|
|
407
710
|
replyTo: parseArray(row.reply_to),
|
|
408
711
|
subject: (row.subject as string) ?? '',
|
|
@@ -444,13 +747,15 @@ export type InboxPage = {
|
|
|
444
747
|
export async function searchInbox(options: {
|
|
445
748
|
text?: string
|
|
446
749
|
owner?: string
|
|
447
|
-
folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
750
|
+
folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
|
|
448
751
|
unread?: boolean
|
|
449
752
|
starred?: boolean
|
|
450
753
|
hasAttachment?: boolean
|
|
451
754
|
label?: string
|
|
452
755
|
from?: string
|
|
453
756
|
to?: string
|
|
757
|
+
/** direct | copied | other, or 'not-copied' to leave copies out. */
|
|
758
|
+
addressed?: string
|
|
454
759
|
limit?: number
|
|
455
760
|
offset?: number
|
|
456
761
|
cursor?: string | null
|
|
@@ -485,16 +790,23 @@ export async function searchInbox(options: {
|
|
|
485
790
|
if (options.label) { where.push('m.labels LIKE ?'); args.push(`%${options.label}%`) }
|
|
486
791
|
if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
|
|
487
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) }
|
|
488
797
|
|
|
489
798
|
// A snoozed message is only out of the inbox while its time is still ahead; the clause
|
|
490
799
|
// does the waking, so nothing has to run on a timer.
|
|
491
800
|
const nowIso = new Date().toISOString()
|
|
492
801
|
const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
else if (options.folder === '
|
|
497
|
-
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) }
|
|
498
810
|
|
|
499
811
|
const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
|
500
812
|
|
|
@@ -524,7 +836,8 @@ export async function searchInbox(options: {
|
|
|
524
836
|
'contentType', json_extract(value, '$.contentType'),
|
|
525
837
|
'size', json_extract(value, '$.size')))
|
|
526
838
|
FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
|
|
527
|
-
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
|
|
528
841
|
FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
|
|
529
842
|
ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
|
|
530
843
|
cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
|
|
@@ -570,6 +883,7 @@ export type FolderTally = {
|
|
|
570
883
|
starred: number
|
|
571
884
|
archived: number
|
|
572
885
|
trashed: number
|
|
886
|
+
spam: number
|
|
573
887
|
snoozed: number
|
|
574
888
|
}
|
|
575
889
|
|
|
@@ -591,9 +905,13 @@ const COUNTS_CACHE_MS = 5 * 60 * 1000
|
|
|
591
905
|
|
|
592
906
|
/** countFolders through a durable cache: one row read when fresh, a full scan only when stale. */
|
|
593
907
|
export async function invalidateCounts(owner: string | null | undefined): Promise<void> {
|
|
594
|
-
if (!owner) return
|
|
595
908
|
try {
|
|
596
|
-
|
|
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'`
|
|
597
915
|
} catch {
|
|
598
916
|
}
|
|
599
917
|
}
|
|
@@ -630,12 +948,13 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
630
948
|
const rows = await tagged(
|
|
631
949
|
sql,
|
|
632
950
|
`SELECT
|
|
633
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
|
|
634
|
-
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,
|
|
635
|
-
SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
|
|
636
|
-
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,
|
|
637
955
|
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
638
|
-
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
|
|
639
958
|
FROM mail_inbox ${scope}`,
|
|
640
959
|
[nowIso, nowIso, nowIso, ...args],
|
|
641
960
|
)
|
|
@@ -656,6 +975,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
656
975
|
SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
|
|
657
976
|
SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
|
|
658
977
|
SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
|
|
978
|
+
0 AS spam,
|
|
659
979
|
SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
|
|
660
980
|
FROM mail_threads WHERE owner = ?`,
|
|
661
981
|
[nowIso, nowIso, nowIso, owner.toLowerCase()],
|
|
@@ -668,6 +988,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
668
988
|
starred: threadValue('starred'),
|
|
669
989
|
archived: threadValue('archived'),
|
|
670
990
|
trashed: threadValue('trashed'),
|
|
991
|
+
spam: threadValue('spam'),
|
|
671
992
|
snoozed: threadValue('snoozed'),
|
|
672
993
|
}
|
|
673
994
|
}
|
|
@@ -678,6 +999,7 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
678
999
|
starred: value('starred'),
|
|
679
1000
|
archived: value('archived'),
|
|
680
1001
|
trashed: value('trashed'),
|
|
1002
|
+
spam: value('spam'),
|
|
681
1003
|
snoozed: value('snoozed'),
|
|
682
1004
|
conversations,
|
|
683
1005
|
}
|
|
@@ -689,10 +1011,10 @@ export async function readInbox(filter?: { owner?: string }): Promise<InboundEma
|
|
|
689
1011
|
const owner = filter?.owner?.trim().toLowerCase()
|
|
690
1012
|
const rows = owner
|
|
691
1013
|
? await sql`
|
|
692
|
-
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
|
|
693
1015
|
FROM mail_inbox WHERE lower(owner) = ${owner} ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
|
|
694
1016
|
: await sql`
|
|
695
|
-
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
|
|
696
1018
|
FROM mail_inbox ORDER BY received_at DESC LIMIT ${MAX_INBOX}`
|
|
697
1019
|
return rows.map(mapInbound)
|
|
698
1020
|
}
|
|
@@ -710,6 +1032,7 @@ export type ThreadRow = {
|
|
|
710
1032
|
inboxCount: number
|
|
711
1033
|
archivedCount: number
|
|
712
1034
|
trashedCount: number
|
|
1035
|
+
spamCount: number
|
|
713
1036
|
attachCount: number
|
|
714
1037
|
senders: string[]
|
|
715
1038
|
snippet: string
|
|
@@ -748,12 +1071,14 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
748
1071
|
const owner = ownerRaw.toLowerCase()
|
|
749
1072
|
const agg = await sql`
|
|
750
1073
|
SELECT COUNT(*) AS n,
|
|
751
|
-
SUM(CASE WHEN read = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS unread,
|
|
752
|
-
SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
|
|
753
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
|
|
754
|
-
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,
|
|
755
1079
|
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
756
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,
|
|
757
1082
|
MIN(received_at) AS first_at, MAX(received_at) AS latest_at
|
|
758
1083
|
FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
|
|
759
1084
|
const total = Number(agg[0]?.n ?? 0)
|
|
@@ -762,7 +1087,7 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
762
1087
|
return
|
|
763
1088
|
}
|
|
764
1089
|
const latest = await sql`
|
|
765
|
-
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
|
|
766
1091
|
FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}
|
|
767
1092
|
ORDER BY received_at DESC, id DESC LIMIT 1`
|
|
768
1093
|
const members = await sql`
|
|
@@ -778,19 +1103,22 @@ export async function refreshThread(ownerRaw: string, threadId: string): Promise
|
|
|
778
1103
|
const head = latest[0]
|
|
779
1104
|
await sql`
|
|
780
1105
|
INSERT INTO mail_threads (owner, thread_id, subject_key, subject, first_at, latest_at, latest_id, count,
|
|
781
|
-
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)
|
|
782
1107
|
VALUES (${owner}, ${threadId}, ${subjectKey(String(head?.subject ?? ''))}, ${head?.subject ?? null},
|
|
783
1108
|
${String(agg[0].first_at)}, ${String(agg[0].latest_at)}, ${head?.id ?? null}, ${total},
|
|
784
1109
|
${Number(agg[0].unread ?? 0)}, ${Number(agg[0].starred ?? 0)}, ${Number(agg[0].inbox ?? 0)},
|
|
785
|
-
${Number(agg[0].archived ?? 0)}, ${Number(agg[0].trashed ?? 0)}, ${Number(agg[0].attach ?? 0)},
|
|
786
|
-
${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'})
|
|
787
1114
|
ON CONFLICT (owner, thread_id) DO UPDATE SET
|
|
788
1115
|
subject_key = excluded.subject_key, subject = excluded.subject, first_at = excluded.first_at,
|
|
789
1116
|
latest_at = excluded.latest_at, latest_id = excluded.latest_id, count = excluded.count,
|
|
790
1117
|
unread_count = excluded.unread_count, starred_count = excluded.starred_count,
|
|
791
1118
|
inbox_count = excluded.inbox_count, archived_count = excluded.archived_count,
|
|
792
|
-
trashed_count = excluded.trashed_count, attach_count = excluded.attach_count,
|
|
793
|
-
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`
|
|
794
1122
|
}
|
|
795
1123
|
|
|
796
1124
|
/** Thread the message and refresh its summary; never lets a threading fault fail a write. */
|
|
@@ -831,7 +1159,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
|
|
|
831
1159
|
}
|
|
832
1160
|
}
|
|
833
1161
|
|
|
834
|
-
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
1162
|
+
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed' | 'spam'
|
|
835
1163
|
|
|
836
1164
|
/** The newest conversations in a folder: one row each, already summarised. */
|
|
837
1165
|
export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
|
|
@@ -841,7 +1169,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
841
1169
|
const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
|
|
842
1170
|
const nowIso = new Date().toISOString()
|
|
843
1171
|
const predicate =
|
|
844
|
-
folder === '
|
|
1172
|
+
folder === 'spam' ? 'spam_count > 0'
|
|
1173
|
+
: folder === 'archive' ? 'archived_count > 0'
|
|
845
1174
|
: folder === 'trash' ? 'trashed_count > 0'
|
|
846
1175
|
: folder === 'starred' ? 'starred_count > 0'
|
|
847
1176
|
: folder === 'snoozed' ? 'snoozed_until > ?'
|
|
@@ -861,8 +1190,8 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
861
1190
|
SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
|
|
862
1191
|
SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
|
|
863
1192
|
SUM(inbox_count) AS inbox_count, SUM(archived_count) AS archived_count,
|
|
864
|
-
SUM(trashed_count) AS trashed_count, SUM(attach_count) AS attach_count,
|
|
865
|
-
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
|
|
866
1195
|
FROM mail_threads
|
|
867
1196
|
GROUP BY thread_id
|
|
868
1197
|
HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
|
|
@@ -870,7 +1199,7 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
870
1199
|
[...folderArgs, ...cursorArgs, limit])
|
|
871
1200
|
: await tagged(db(), `
|
|
872
1201
|
SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
|
|
873
|
-
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
|
|
874
1203
|
FROM mail_threads WHERE owner = ? AND ${predicate}
|
|
875
1204
|
${cursorClause ? `AND ${cursorClause}` : ''}
|
|
876
1205
|
ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
|
|
@@ -889,11 +1218,14 @@ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder,
|
|
|
889
1218
|
inboxCount: Number(row.inbox_count ?? 0),
|
|
890
1219
|
archivedCount: Number(row.archived_count ?? 0),
|
|
891
1220
|
trashedCount: Number(row.trashed_count ?? 0),
|
|
1221
|
+
spamCount: Number(row.spam_count ?? 0),
|
|
892
1222
|
attachCount: Number(row.attach_count ?? 0),
|
|
893
1223
|
senders: parseJson<string[]>(row.senders, []),
|
|
894
1224
|
snippet: String(row.snippet ?? ''),
|
|
895
1225
|
labels: parseJson<string[]>(row.labels, []),
|
|
896
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,
|
|
897
1229
|
}))
|
|
898
1230
|
return { rows: mapped, nextCursor }
|
|
899
1231
|
}
|
|
@@ -963,13 +1295,107 @@ export async function appendInbound(
|
|
|
963
1295
|
await ensureMailSchema()
|
|
964
1296
|
const sql = db()
|
|
965
1297
|
await sql`
|
|
966
|
-
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)
|
|
967
|
-
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})
|
|
968
1300
|
ON CONFLICT (id) DO NOTHING`
|
|
969
1301
|
await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
|
|
970
1302
|
await invalidateCounts(email.owner)
|
|
971
1303
|
}
|
|
972
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
|
+
|
|
973
1399
|
export async function getInboundSource(
|
|
974
1400
|
id: string,
|
|
975
1401
|
): Promise<{ owner: string | null; attachments: Array<Record<string, unknown>> } | null> {
|
|
@@ -1217,13 +1643,44 @@ export async function listInboundWithAttachments(owner: string | null): Promise<
|
|
|
1217
1643
|
export async function setInboxOwner(id: string, owner: string | null): Promise<void> {
|
|
1218
1644
|
const sql = db()
|
|
1219
1645
|
const before = await sql`SELECT owner, thread_id FROM mail_inbox WHERE id = ${id}`
|
|
1220
|
-
|
|
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)
|
|
1221
1653
|
await rethreadAfterChange(id, before[0]?.owner == null ? null : String(before[0].owner), before[0]?.thread_id == null ? null : String(before[0].thread_id))
|
|
1222
1654
|
}
|
|
1223
1655
|
|
|
1224
1656
|
export async function markInboundRead(id: string): Promise<void> {
|
|
1225
1657
|
const sql = db()
|
|
1226
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))
|
|
1227
1684
|
await rethreadAfterChange(id)
|
|
1228
1685
|
}
|
|
1229
1686
|
|
|
@@ -1233,7 +1690,12 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
|
|
|
1233
1690
|
if (flags.starred !== undefined) await sql`UPDATE mail_inbox SET starred = ${flags.starred} WHERE id = ${id}`
|
|
1234
1691
|
if (flags.archived !== undefined) await sql`UPDATE mail_inbox SET archived = ${flags.archived} WHERE id = ${id}`
|
|
1235
1692
|
if (flags.trashed !== undefined) await sql`UPDATE mail_inbox SET trashed = ${flags.trashed} WHERE id = ${id}`
|
|
1236
|
-
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
|
+
}
|
|
1237
1699
|
await invalidateCounts(owned[0]?.owner == null ? null : String(owned[0].owner))
|
|
1238
1700
|
await rethreadAfterChange(id)
|
|
1239
1701
|
}
|
|
@@ -1299,6 +1761,8 @@ export async function inboundExists(ids: string[]): Promise<Set<string>> {
|
|
|
1299
1761
|
export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
|
|
1300
1762
|
const sql = db()
|
|
1301
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))
|
|
1302
1766
|
}
|
|
1303
1767
|
|
|
1304
1768
|
// ── Sent-mail flags (star / archive / trash on Resend-sent emails) ──
|
|
@@ -1484,6 +1948,9 @@ export type MailAccount = {
|
|
|
1484
1948
|
hasPassword: boolean
|
|
1485
1949
|
createdAt: string | null
|
|
1486
1950
|
invitedBy: string | null
|
|
1951
|
+
/** Outside address that can receive a reset link. Only usable once proven. */
|
|
1952
|
+
recoveryEmail: string | null
|
|
1953
|
+
recoveryVerified: boolean
|
|
1487
1954
|
}
|
|
1488
1955
|
|
|
1489
1956
|
function mapAccount(row: Record<string, unknown>): MailAccount {
|
|
@@ -1496,27 +1963,29 @@ function mapAccount(row: Record<string, unknown>): MailAccount {
|
|
|
1496
1963
|
hasPassword: Boolean(row.has_password),
|
|
1497
1964
|
createdAt: isoOrNull(row.created_at),
|
|
1498
1965
|
invitedBy: (row.invited_by as string) ?? null,
|
|
1966
|
+
recoveryEmail: (row.recovery_email as string) ?? null,
|
|
1967
|
+
recoveryVerified: Boolean(Number(row.recovery_verified ?? 0)),
|
|
1499
1968
|
}
|
|
1500
1969
|
}
|
|
1501
1970
|
|
|
1502
1971
|
export async function listAccounts(): Promise<MailAccount[]> {
|
|
1503
1972
|
await ensureMailSchema()
|
|
1504
1973
|
const sql = db()
|
|
1505
|
-
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
|
|
1974
|
+
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
|
|
1506
1975
|
return rows.map(mapAccount)
|
|
1507
1976
|
}
|
|
1508
1977
|
|
|
1509
1978
|
export async function getAccount(email: string): Promise<MailAccount | null> {
|
|
1510
1979
|
await ensureMailSchema()
|
|
1511
1980
|
const sql = db()
|
|
1512
|
-
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
|
|
1981
|
+
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
|
|
1513
1982
|
return rows[0] ? mapAccount(rows[0]) : null
|
|
1514
1983
|
}
|
|
1515
1984
|
|
|
1516
1985
|
export async function getAccountByAddress(address: string): Promise<MailAccount | null> {
|
|
1517
1986
|
await ensureMailSchema()
|
|
1518
1987
|
const sql = db()
|
|
1519
|
-
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
|
|
1988
|
+
const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
|
|
1520
1989
|
return rows[0] ? mapAccount(rows[0]) : null
|
|
1521
1990
|
}
|
|
1522
1991
|
|
|
@@ -1552,6 +2021,27 @@ export async function deleteAccount(email: string): Promise<void> {
|
|
|
1552
2021
|
await sql`DELETE FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
|
|
1553
2022
|
}
|
|
1554
2023
|
|
|
2024
|
+
/** Record a recovery address as claimed but unproven. Re-saving the same one re-arms it. */
|
|
2025
|
+
export async function setRecoveryEmail(email: string, recovery: string | null): Promise<void> {
|
|
2026
|
+
await ensureMailSchema()
|
|
2027
|
+
const sql = db()
|
|
2028
|
+
const normalized = recovery ? recovery.trim().toLowerCase() : null
|
|
2029
|
+
await sql`
|
|
2030
|
+
UPDATE mail_accounts SET recovery_email = ${normalized}, recovery_verified = 0
|
|
2031
|
+
WHERE email = ${email.trim().toLowerCase()}`
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Prove the address: only the one currently on the account, so a stale link cannot land. */
|
|
2035
|
+
export async function markRecoveryVerified(email: string, recovery: string): Promise<boolean> {
|
|
2036
|
+
await ensureMailSchema()
|
|
2037
|
+
const sql = db()
|
|
2038
|
+
const rows = await sql`
|
|
2039
|
+
UPDATE mail_accounts SET recovery_verified = 1
|
|
2040
|
+
WHERE email = ${email.trim().toLowerCase()} AND lower(recovery_email) = ${recovery.trim().toLowerCase()}
|
|
2041
|
+
RETURNING email`
|
|
2042
|
+
return rows.length > 0
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1555
2045
|
// ── Sent-mail archive (our own copy, independent of any provider) ─
|
|
1556
2046
|
export type SentMessage = {
|
|
1557
2047
|
id: string
|
|
@@ -1569,6 +2059,8 @@ export type SentMessage = {
|
|
|
1569
2059
|
attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
|
|
1570
2060
|
/** How many files the list should raise a paperclip for, counted the way the inbox counts. */
|
|
1571
2061
|
attachmentCount?: number
|
|
2062
|
+
/** Sent by the app itself — an invite, a reset, an auto-reply — rather than by a person. */
|
|
2063
|
+
isAuto?: boolean
|
|
1572
2064
|
}
|
|
1573
2065
|
|
|
1574
2066
|
export async function recordSentMessage(message: SentMessage): Promise<void> {
|
|
@@ -1665,11 +2157,12 @@ export async function readSentArchive(options: {
|
|
|
1665
2157
|
sharedAddress?: string | null
|
|
1666
2158
|
query?: ParsedQuery | null
|
|
1667
2159
|
limit?: number
|
|
2160
|
+
includeAuto?: boolean
|
|
1668
2161
|
} = {}): Promise<SentMessage[]> {
|
|
1669
2162
|
await ensureMailSchema()
|
|
1670
2163
|
const sql = db()
|
|
1671
2164
|
const limit = Math.min(Math.max(options.limit ?? 500, 1), 1000)
|
|
1672
|
-
const where: string[] = ['coalesce(m.is_auto, 0) = 0']
|
|
2165
|
+
const where: string[] = options.includeAuto ? [] : ['coalesce(m.is_auto, 0) = 0']
|
|
1673
2166
|
const args: unknown[] = []
|
|
1674
2167
|
|
|
1675
2168
|
const owner = options.ownerAddress?.trim().toLowerCase()
|
|
@@ -1691,6 +2184,7 @@ export async function readSentArchive(options: {
|
|
|
1691
2184
|
sql,
|
|
1692
2185
|
`SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
|
|
1693
2186
|
json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
|
|
2187
|
+
coalesce(m.is_auto, 0) AS is_auto,
|
|
1694
2188
|
${textColumn} AS text
|
|
1695
2189
|
FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
|
|
1696
2190
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
@@ -1710,12 +2204,18 @@ export async function readSentArchive(options: {
|
|
|
1710
2204
|
createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
|
|
1711
2205
|
lastEvent: (row.last_event as string) ?? null,
|
|
1712
2206
|
attachmentCount: Number(row.attach_count ?? 0),
|
|
2207
|
+
isAuto: Boolean(Number(row.is_auto ?? 0)),
|
|
1713
2208
|
}))
|
|
1714
2209
|
}
|
|
1715
2210
|
|
|
1716
2211
|
// ── Sent-mail attribution (owner + automated flag + thread link) ─
|
|
1717
2212
|
export type SentMeta = { owner: string | null; isAuto: boolean; inReplyTo: string | null }
|
|
1718
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
|
+
|
|
1719
2219
|
export async function recordSentMeta(
|
|
1720
2220
|
emailId: string,
|
|
1721
2221
|
owner: string | null,
|
|
@@ -1931,13 +2431,29 @@ export async function revokeShare(id: string, owner: string): Promise<boolean> {
|
|
|
1931
2431
|
return rows.length > 0
|
|
1932
2432
|
}
|
|
1933
2433
|
|
|
1934
|
-
export async function createResetToken(
|
|
2434
|
+
export async function createResetToken(
|
|
2435
|
+
email: string,
|
|
2436
|
+
token: string,
|
|
2437
|
+
expires: number,
|
|
2438
|
+
purpose: 'reset' | 'verify-recovery' = 'reset',
|
|
2439
|
+
): Promise<void> {
|
|
1935
2440
|
await ensureMailSchema()
|
|
1936
2441
|
const sql = db()
|
|
1937
2442
|
await sql`DELETE FROM mail_reset_tokens WHERE expires_at < ${nowIso()}`
|
|
1938
2443
|
await sql`
|
|
1939
|
-
INSERT INTO mail_reset_tokens (token, email, expires_at)
|
|
1940
|
-
VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()})`
|
|
2444
|
+
INSERT INTO mail_reset_tokens (token, email, expires_at, purpose)
|
|
2445
|
+
VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()}, ${purpose})`
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Spend a non-reset token, returning the address it was issued for. */
|
|
2449
|
+
export async function consumeToken(token: string, purpose: 'verify-recovery'): Promise<string | null> {
|
|
2450
|
+
await ensureMailSchema()
|
|
2451
|
+
const sql = db()
|
|
2452
|
+
const rows = await sql`
|
|
2453
|
+
DELETE FROM mail_reset_tokens
|
|
2454
|
+
WHERE token = ${token} AND purpose = ${purpose} AND expires_at > ${nowIso()}
|
|
2455
|
+
RETURNING email`
|
|
2456
|
+
return rows[0]?.email ? String(rows[0].email) : null
|
|
1941
2457
|
}
|
|
1942
2458
|
|
|
1943
2459
|
/**
|
|
@@ -1948,7 +2464,8 @@ export async function resetTokenEmail(token: string): Promise<string | null> {
|
|
|
1948
2464
|
await ensureMailSchema()
|
|
1949
2465
|
const sql = db()
|
|
1950
2466
|
const rows = await sql`
|
|
1951
|
-
SELECT email FROM mail_reset_tokens
|
|
2467
|
+
SELECT email FROM mail_reset_tokens
|
|
2468
|
+
WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}`
|
|
1952
2469
|
const email = rows[0]?.email
|
|
1953
2470
|
return email ? String(email) : null
|
|
1954
2471
|
}
|
|
@@ -1958,12 +2475,17 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
|
|
|
1958
2475
|
await ensureMailSchema()
|
|
1959
2476
|
const sql = db()
|
|
1960
2477
|
const consumed = await sql`
|
|
1961
|
-
DELETE FROM mail_reset_tokens
|
|
2478
|
+
DELETE FROM mail_reset_tokens
|
|
2479
|
+
WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}
|
|
2480
|
+
RETURNING email`
|
|
1962
2481
|
const email = consumed[0]?.email
|
|
1963
2482
|
if (!email) return null
|
|
1964
2483
|
await sql`
|
|
1965
2484
|
INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
|
|
1966
|
-
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`
|
|
1967
2489
|
// Any other link still outstanding for this address is now stale — and one of them
|
|
1968
2490
|
// may be the reason the password is being changed.
|
|
1969
2491
|
await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
|
|
@@ -2117,7 +2639,8 @@ export async function backfillAttachments(limit: number, countRemaining = false)
|
|
|
2117
2639
|
rows.map(async row => {
|
|
2118
2640
|
const id = String(row.id)
|
|
2119
2641
|
try {
|
|
2120
|
-
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`, {
|
|
2121
2644
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
2122
2645
|
})
|
|
2123
2646
|
if (!listing.ok) {
|
|
@@ -2140,10 +2663,14 @@ export async function backfillAttachments(limit: number, countRemaining = false)
|
|
|
2140
2663
|
const contentType = entry.content_type ? String(entry.content_type) : undefined
|
|
2141
2664
|
const source = entry.download_url ? String(entry.download_url) : ''
|
|
2142
2665
|
const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0) }
|
|
2143
|
-
if (!source) return meta
|
|
2144
|
-
|
|
2145
|
-
if (
|
|
2146
|
-
|
|
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
|
+
}
|
|
2147
2674
|
const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
|
|
2148
2675
|
const key = `attachments/${id}/${index}-${safeName}`
|
|
2149
2676
|
if (!(await putObject(key, bytes, contentType))) return meta
|