@novacraft-engineering/mailbox 0.1.0 → 0.2.0
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 +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/lib/mailbox.ts
CHANGED
|
@@ -11,12 +11,14 @@ import { ADDRESS_ALIASES, MAIL_SEATS, type MailRole } from './brand'
|
|
|
11
11
|
import { d1, d1Batch, d1Query } from './d1'
|
|
12
12
|
import { stripCidPlaceholders } from './email-html'
|
|
13
13
|
import { hashPassword } from './password'
|
|
14
|
+
import { duration, type ParsedQuery } from '@/app/mail/search'
|
|
14
15
|
import { turso, tursoBatch, tursoQuery } from './turso'
|
|
15
16
|
import { subjectKey, threadIdFor, THREAD_GAP_MS } from './threads'
|
|
16
17
|
|
|
17
18
|
export type InboundAttachment = { filename: string; contentType?: string; size?: number; shareId?: string }
|
|
18
19
|
|
|
19
20
|
export type InboundEmail = {
|
|
21
|
+
snoozedUntil?: string | null
|
|
20
22
|
id: string
|
|
21
23
|
from: string
|
|
22
24
|
to: string[]
|
|
@@ -143,6 +145,11 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
143
145
|
// sort it and then discards all but one page.
|
|
144
146
|
`CREATE INDEX IF NOT EXISTS mail_inbox_list_idx ON mail_inbox (lower(owner), archived, trashed, received_at DESC, id DESC)`,
|
|
145
147
|
`CREATE INDEX IF NOT EXISTS mail_inbox_owner_recent_idx ON mail_inbox (lower(owner), received_at DESC, id DESC)`,
|
|
148
|
+
// Trash and Starred cannot use the list index: archived sits ahead of trashed in it
|
|
149
|
+
// and is unconstrained for those two folders, so SQLite fell back to walking the
|
|
150
|
+
// mailbox in date order — 45,000 rows read to find the 52 that were in the bin.
|
|
151
|
+
`CREATE INDEX IF NOT EXISTS mail_inbox_owner_trash_idx ON mail_inbox (lower(owner), trashed, received_at DESC, id DESC)`,
|
|
152
|
+
`CREATE INDEX IF NOT EXISTS mail_inbox_owner_starred_idx ON mail_inbox (lower(owner), starred, trashed, received_at DESC, id DESC)`,
|
|
146
153
|
// The folder counts read only these five columns. Without them all in one index
|
|
147
154
|
// SQLite walks the rows themselves, and a row here can carry 50KB of html — which
|
|
148
155
|
// turned a five-number summary into a 37-second scan on the larger mailboxes.
|
|
@@ -350,6 +357,15 @@ export function ensureMailSchema(): Promise<void> {
|
|
|
350
357
|
await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN thread_id TEXT').catch(() => {})
|
|
351
358
|
await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_thread_idx ON mail_inbox (lower(owner), thread_id, received_at DESC, id DESC)').catch(() => {})
|
|
352
359
|
|
|
360
|
+
// Snooze. An ISO timestamp in the future means "not now": the message is out of the
|
|
361
|
+
// inbox until then and comes back on its own, because every folder decides from this
|
|
362
|
+
// column against the clock rather than from a flag some worker has to flip. There is
|
|
363
|
+
// no job to fall behind, and nothing to reconcile if one does.
|
|
364
|
+
await sqlRaw('ALTER TABLE mail_inbox ADD COLUMN snoozed_until TEXT').catch(() => {})
|
|
365
|
+
await sqlRaw('ALTER TABLE mail_threads ADD COLUMN snoozed_until TEXT').catch(() => {})
|
|
366
|
+
await sqlRaw('CREATE INDEX IF NOT EXISTS mail_inbox_snoozed_idx ON mail_inbox (lower(owner), snoozed_until, received_at DESC, id DESC)').catch(() => {})
|
|
367
|
+
await sqlRaw('CREATE INDEX IF NOT EXISTS mail_threads_snoozed_idx ON mail_threads (owner, snoozed_until, latest_at DESC)').catch(() => {})
|
|
368
|
+
|
|
353
369
|
// Seed the mailboxes this deployment serves — metadata only, never clobber an
|
|
354
370
|
// existing password_hash.
|
|
355
371
|
const sql = db()
|
|
@@ -399,6 +415,7 @@ function mapInbound(row: Record<string, unknown>): InboundEmail {
|
|
|
399
415
|
starred: Boolean(row.starred),
|
|
400
416
|
archived: Boolean(row.archived),
|
|
401
417
|
trashed: Boolean(row.trashed),
|
|
418
|
+
snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
|
|
402
419
|
labels: parseArray(row.labels),
|
|
403
420
|
owner: (row.owner as string) ?? null,
|
|
404
421
|
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
@@ -427,7 +444,7 @@ export type InboxPage = {
|
|
|
427
444
|
export async function searchInbox(options: {
|
|
428
445
|
text?: string
|
|
429
446
|
owner?: string
|
|
430
|
-
folder?: 'inbox' | 'archive' | 'trash' | 'starred'
|
|
447
|
+
folder?: 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
431
448
|
unread?: boolean
|
|
432
449
|
starred?: boolean
|
|
433
450
|
hasAttachment?: boolean
|
|
@@ -469,10 +486,15 @@ export async function searchInbox(options: {
|
|
|
469
486
|
if (options.from) { where.push('lower(m.from_addr) LIKE ?'); args.push(`%${options.from.toLowerCase()}%`) }
|
|
470
487
|
if (options.to) { where.push('lower(m.to_addrs) LIKE ?'); args.push(`%${options.to.toLowerCase()}%`) }
|
|
471
488
|
|
|
489
|
+
// A snoozed message is only out of the inbox while its time is still ahead; the clause
|
|
490
|
+
// does the waking, so nothing has to run on a timer.
|
|
491
|
+
const nowIso = new Date().toISOString()
|
|
492
|
+
const awake = "(m.snoozed_until IS NULL OR m.snoozed_until <= ?)"
|
|
472
493
|
if (options.folder === 'trash') where.push('m.trashed = 1')
|
|
473
494
|
else if (options.folder === 'archive') where.push('m.archived = 1 AND m.trashed = 0')
|
|
474
495
|
else if (options.folder === 'starred') where.push('m.starred = 1 AND m.trashed = 0')
|
|
475
|
-
else if (options.folder === '
|
|
496
|
+
else if (options.folder === 'snoozed') { where.push('m.snoozed_until > ? AND m.trashed = 0'); args.push(nowIso) }
|
|
497
|
+
else if (options.folder === 'inbox') { where.push(`m.archived = 0 AND m.trashed = 0 AND ${awake}`); args.push(nowIso) }
|
|
476
498
|
|
|
477
499
|
const filterClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
|
478
500
|
|
|
@@ -502,7 +524,7 @@ export async function searchInbox(options: {
|
|
|
502
524
|
'contentType', json_extract(value, '$.contentType'),
|
|
503
525
|
'size', json_extract(value, '$.size')))
|
|
504
526
|
FROM json_each(CASE WHEN json_valid(m.attachments) THEN m.attachments ELSE '[]' END)), '[]') AS attachments,
|
|
505
|
-
m.starred, m.archived, m.trashed, m.labels, m.owner, m.thread_id
|
|
527
|
+
m.starred, m.archived, m.trashed, m.snoozed_until, m.labels, m.owner, m.thread_id
|
|
506
528
|
FROM ${ftsFrom}mail_inbox m ${ftsFrom ? 'ON m.rowid = fts.fts_rid' : ''} ${pageClause}
|
|
507
529
|
ORDER BY m.received_at DESC, m.id DESC LIMIT ?${cursor ? '' : ' OFFSET ?'}`,
|
|
508
530
|
cursor ? [...pageArgs, limit] : [...pageArgs, limit, offset],
|
|
@@ -542,14 +564,21 @@ function decodeCursor(value?: string | null): { receivedAt: string; id: string }
|
|
|
542
564
|
}
|
|
543
565
|
}
|
|
544
566
|
|
|
545
|
-
export type
|
|
567
|
+
export type FolderTally = {
|
|
546
568
|
inbox: number
|
|
547
569
|
unread: number
|
|
548
570
|
starred: number
|
|
549
571
|
archived: number
|
|
550
572
|
trashed: number
|
|
573
|
+
snoozed: number
|
|
551
574
|
}
|
|
552
575
|
|
|
576
|
+
/**
|
|
577
|
+
* `conversations` is null when threads are not live, because `mail_threads` is then either
|
|
578
|
+
* empty or half-built and counting it would report a mailbox smaller than it is.
|
|
579
|
+
*/
|
|
580
|
+
export type FolderCounts = FolderTally & { conversations: FolderTally | null }
|
|
581
|
+
|
|
553
582
|
/**
|
|
554
583
|
* Folder totals for a whole mailbox.
|
|
555
584
|
*
|
|
@@ -577,7 +606,9 @@ export async function countFoldersCached(owner: string): Promise<FolderCounts> {
|
|
|
577
606
|
const row = hit[0]
|
|
578
607
|
if (row && Date.now() - Date.parse(String(row.computed_at)) < COUNTS_CACHE_MS) {
|
|
579
608
|
const parsed = parseJson<FolderCounts | null>(row.counts, null)
|
|
580
|
-
|
|
609
|
+
// A row cached before conversations were counted has no such key; recompute rather
|
|
610
|
+
// than serve a shape the caller will read as "threads are not live".
|
|
611
|
+
if (parsed && 'conversations' in parsed) return parsed
|
|
581
612
|
}
|
|
582
613
|
const fresh = await countFolders(owner)
|
|
583
614
|
await sql`
|
|
@@ -592,28 +623,63 @@ export async function countFolders(owner?: string): Promise<FolderCounts> {
|
|
|
592
623
|
const sql = db()
|
|
593
624
|
const scope = owner ? 'WHERE lower(owner) = ?' : ''
|
|
594
625
|
const args = owner ? [owner.toLowerCase()] : []
|
|
626
|
+
const nowIso = new Date().toISOString()
|
|
595
627
|
|
|
596
628
|
// One pass, not one per folder: five separate counts read the table five times
|
|
597
629
|
// (206k rows against 69k here) for figures that all come off the same scan.
|
|
598
630
|
const rows = await tagged(
|
|
599
631
|
sql,
|
|
600
632
|
`SELECT
|
|
601
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 THEN 1 ELSE 0 END) AS inbox,
|
|
602
|
-
SUM(CASE WHEN archived = 0 AND trashed = 0 AND read = 0 THEN 1 ELSE 0 END) AS unread,
|
|
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,
|
|
603
635
|
SUM(CASE WHEN starred = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS starred,
|
|
604
636
|
SUM(CASE WHEN archived = 1 AND trashed = 0 THEN 1 ELSE 0 END) AS archived,
|
|
605
|
-
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed
|
|
637
|
+
SUM(CASE WHEN trashed = 1 THEN 1 ELSE 0 END) AS trashed,
|
|
638
|
+
SUM(CASE WHEN snoozed_until > ? AND trashed = 0 THEN 1 ELSE 0 END) AS snoozed
|
|
606
639
|
FROM mail_inbox ${scope}`,
|
|
607
|
-
args,
|
|
640
|
+
[nowIso, nowIso, nowIso, ...args],
|
|
608
641
|
)
|
|
609
642
|
const row = rows[0] ?? {}
|
|
610
643
|
const value = (key: string) => Number((row[key] as number) ?? 0)
|
|
644
|
+
|
|
645
|
+
// The list shows one row per conversation, so the figure beside a folder has to be
|
|
646
|
+
// conversations too. Counted in messages it disagreed with the list header by three to
|
|
647
|
+
// one on the larger mailboxes, and the unread badge could exceed the folder total.
|
|
648
|
+
// The predicates are the ones listThreads pages by; they must not drift apart.
|
|
649
|
+
let conversations: FolderTally | null = null
|
|
650
|
+
if (threadsLive() && owner) {
|
|
651
|
+
const threadRows = await tagged(
|
|
652
|
+
sql,
|
|
653
|
+
`SELECT
|
|
654
|
+
SUM(CASE WHEN inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS inbox,
|
|
655
|
+
SUM(CASE WHEN inbox_count > 0 AND unread_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?) THEN 1 ELSE 0 END) AS unread,
|
|
656
|
+
SUM(CASE WHEN starred_count > 0 THEN 1 ELSE 0 END) AS starred,
|
|
657
|
+
SUM(CASE WHEN archived_count > 0 THEN 1 ELSE 0 END) AS archived,
|
|
658
|
+
SUM(CASE WHEN trashed_count > 0 THEN 1 ELSE 0 END) AS trashed,
|
|
659
|
+
SUM(CASE WHEN snoozed_until > ? THEN 1 ELSE 0 END) AS snoozed
|
|
660
|
+
FROM mail_threads WHERE owner = ?`,
|
|
661
|
+
[nowIso, nowIso, nowIso, owner.toLowerCase()],
|
|
662
|
+
)
|
|
663
|
+
const threadRow = threadRows[0] ?? {}
|
|
664
|
+
const threadValue = (key: string) => Number((threadRow[key] as number) ?? 0)
|
|
665
|
+
conversations = {
|
|
666
|
+
inbox: threadValue('inbox'),
|
|
667
|
+
unread: threadValue('unread'),
|
|
668
|
+
starred: threadValue('starred'),
|
|
669
|
+
archived: threadValue('archived'),
|
|
670
|
+
trashed: threadValue('trashed'),
|
|
671
|
+
snoozed: threadValue('snoozed'),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
611
675
|
return {
|
|
612
676
|
inbox: value('inbox'),
|
|
613
677
|
unread: value('unread'),
|
|
614
678
|
starred: value('starred'),
|
|
615
679
|
archived: value('archived'),
|
|
616
680
|
trashed: value('trashed'),
|
|
681
|
+
snoozed: value('snoozed'),
|
|
682
|
+
conversations,
|
|
617
683
|
}
|
|
618
684
|
}
|
|
619
685
|
|
|
@@ -648,6 +714,7 @@ export type ThreadRow = {
|
|
|
648
714
|
senders: string[]
|
|
649
715
|
snippet: string
|
|
650
716
|
labels: string[]
|
|
717
|
+
snoozedUntil: string | null
|
|
651
718
|
}
|
|
652
719
|
|
|
653
720
|
/**
|
|
@@ -737,6 +804,8 @@ async function threadMessage(row: { id: string; owner: string | null; subject: s
|
|
|
737
804
|
const threadId = await assignThread({ id: row.id, owner: row.owner, subject: row.subject, receivedAt: row.receivedAt })
|
|
738
805
|
const sql = db()
|
|
739
806
|
await sql`UPDATE mail_inbox SET thread_id = ${threadId} WHERE id = ${row.id}`
|
|
807
|
+
// A reply to a conversation someone put aside is the reason to stop putting it aside.
|
|
808
|
+
await setThreadSnooze(row.owner, threadId, null)
|
|
740
809
|
await refreshThread(row.owner, threadId)
|
|
741
810
|
} catch (err) {
|
|
742
811
|
console.error('[threads] could not thread', row.id, err instanceof Error ? err.message : err)
|
|
@@ -762,7 +831,7 @@ async function rethreadAfterChange(id: string, previousOwner?: string | null, pr
|
|
|
762
831
|
}
|
|
763
832
|
}
|
|
764
833
|
|
|
765
|
-
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred'
|
|
834
|
+
export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
|
|
766
835
|
|
|
767
836
|
/** The newest conversations in a folder: one row each, already summarised. */
|
|
768
837
|
export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
|
|
@@ -770,19 +839,26 @@ export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
|
|
|
770
839
|
export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
|
|
771
840
|
await ensureMailSchema()
|
|
772
841
|
const owner = ownerRaw.toLowerCase()
|
|
842
|
+
const nowIso = new Date().toISOString()
|
|
773
843
|
const predicate =
|
|
774
844
|
folder === 'archive' ? 'archived_count > 0'
|
|
775
845
|
: folder === 'trash' ? 'trashed_count > 0'
|
|
776
846
|
: folder === 'starred' ? 'starred_count > 0'
|
|
777
|
-
: '
|
|
847
|
+
: folder === 'snoozed' ? 'snoozed_until > ?'
|
|
848
|
+
: 'inbox_count > 0 AND (snoozed_until IS NULL OR snoozed_until <= ?)'
|
|
849
|
+
// Both snooze predicates carry one bound timestamp; the others carry none, and the
|
|
850
|
+
// cursor's arguments have to follow whatever the predicate used.
|
|
851
|
+
const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
|
|
778
852
|
const cursor = decodeCursor(cursorRaw)
|
|
779
853
|
const rows = await tagged(db(), `
|
|
780
854
|
SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
|
|
781
|
-
inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels
|
|
855
|
+
inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
|
|
782
856
|
FROM mail_threads WHERE owner = ? AND ${predicate}
|
|
783
857
|
${cursor ? 'AND (latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''}
|
|
784
858
|
ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
|
|
785
|
-
cursor
|
|
859
|
+
cursor
|
|
860
|
+
? [owner, ...folderArgs, cursor.receivedAt, cursor.receivedAt, cursor.id, limit]
|
|
861
|
+
: [owner, ...folderArgs, limit])
|
|
786
862
|
const last = rows[rows.length - 1]
|
|
787
863
|
const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
|
|
788
864
|
const mapped = rows.map(row => ({
|
|
@@ -801,6 +877,7 @@ export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit:
|
|
|
801
877
|
senders: parseJson<string[]>(row.senders, []),
|
|
802
878
|
snippet: String(row.snippet ?? ''),
|
|
803
879
|
labels: parseJson<string[]>(row.labels, []),
|
|
880
|
+
snoozedUntil: row.snoozed_until == null ? null : String(row.snoozed_until),
|
|
804
881
|
}))
|
|
805
882
|
return { rows: mapped, nextCursor }
|
|
806
883
|
}
|
|
@@ -1145,6 +1222,64 @@ export async function setInboundFlags(id: string, flags: InboundFlags): Promise<
|
|
|
1145
1222
|
await rethreadAfterChange(id)
|
|
1146
1223
|
}
|
|
1147
1224
|
|
|
1225
|
+
/**
|
|
1226
|
+
* Flags every message in one conversation. The list shows a row per thread, and a client
|
|
1227
|
+
* that has not loaded that thread's messages cannot name them — which is why archiving a
|
|
1228
|
+
* row whose mail sat below the loaded page quietly did nothing.
|
|
1229
|
+
*/
|
|
1230
|
+
export async function setInboundFlagsForThread(
|
|
1231
|
+
ownerRaw: string,
|
|
1232
|
+
threadId: string,
|
|
1233
|
+
flags: InboundFlags,
|
|
1234
|
+
): Promise<string[]> {
|
|
1235
|
+
await ensureMailSchema()
|
|
1236
|
+
const sql = db()
|
|
1237
|
+
const owner = ownerRaw.toLowerCase()
|
|
1238
|
+
const rows = await sql`
|
|
1239
|
+
SELECT id FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
|
|
1240
|
+
const ids = rows.map(row => String(row.id))
|
|
1241
|
+
for (const id of ids) await setInboundFlags(id, flags)
|
|
1242
|
+
return ids
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Snooze or wake a conversation. Snooze is a property of the conversation rather than of
|
|
1247
|
+
* one message in it: the list shows a row per thread, and half a thread disappearing would
|
|
1248
|
+
* be a puzzle rather than a feature. `until` of null wakes it immediately.
|
|
1249
|
+
*/
|
|
1250
|
+
export async function setThreadSnooze(
|
|
1251
|
+
ownerRaw: string,
|
|
1252
|
+
threadId: string,
|
|
1253
|
+
until: string | null,
|
|
1254
|
+
): Promise<number> {
|
|
1255
|
+
await ensureMailSchema()
|
|
1256
|
+
const sql = db()
|
|
1257
|
+
const owner = ownerRaw.toLowerCase()
|
|
1258
|
+
const rows = await sql`
|
|
1259
|
+
SELECT id FROM mail_inbox WHERE lower(owner) = ${owner} AND thread_id = ${threadId}`
|
|
1260
|
+
for (const row of rows) {
|
|
1261
|
+
await sql`UPDATE mail_inbox SET snoozed_until = ${until} WHERE id = ${String(row.id)}`
|
|
1262
|
+
}
|
|
1263
|
+
await sql`
|
|
1264
|
+
UPDATE mail_threads SET snoozed_until = ${until}
|
|
1265
|
+
WHERE owner = ${owner} AND thread_id = ${threadId}`
|
|
1266
|
+
await invalidateCounts(owner)
|
|
1267
|
+
return rows.length
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** Which of these message ids the mailbox already holds. */
|
|
1271
|
+
export async function inboundExists(ids: string[]): Promise<Set<string>> {
|
|
1272
|
+
await ensureMailSchema()
|
|
1273
|
+
const found = new Set<string>()
|
|
1274
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
1275
|
+
const chunk = ids.slice(i, i + 200)
|
|
1276
|
+
if (!chunk.length) break
|
|
1277
|
+
const rows = await tagged(db(), `SELECT id FROM mail_inbox WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk)
|
|
1278
|
+
for (const row of rows) found.add(String(row.id))
|
|
1279
|
+
}
|
|
1280
|
+
return found
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1148
1283
|
export async function setInboundLabels(id: string, labels: string[]): Promise<void> {
|
|
1149
1284
|
const sql = db()
|
|
1150
1285
|
await sql`UPDATE mail_inbox SET labels = ${JSON.stringify(labels)} WHERE id = ${id}`
|
|
@@ -1428,14 +1563,121 @@ export async function recordSentMessage(message: SentMessage): Promise<void> {
|
|
|
1428
1563
|
ON CONFLICT (id) DO NOTHING`
|
|
1429
1564
|
}
|
|
1430
1565
|
|
|
1431
|
-
|
|
1566
|
+
/**
|
|
1567
|
+
* Which columns a search term can narrow in SQL. The precise decision is still made by
|
|
1568
|
+
* matchesQuery on the rows that come back; this only keeps the scan from reading every
|
|
1569
|
+
* body in the archive. A term it cannot express leaves its group unnarrowed rather than
|
|
1570
|
+
* narrowing it wrongly.
|
|
1571
|
+
*/
|
|
1572
|
+
const SENT_SEARCH_COLUMNS: Record<string, string> = {
|
|
1573
|
+
to: 's.to_addrs',
|
|
1574
|
+
cc: 's.cc',
|
|
1575
|
+
bcc: 's.bcc',
|
|
1576
|
+
from: 's.from_addr',
|
|
1577
|
+
subject: 's.subject',
|
|
1578
|
+
// Must match the substr the exact matcher is handed, or a deep hit passes SQL then drops.
|
|
1579
|
+
body: "substr(coalesce(s.body_text, ''), 1, 4000)",
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** A LIKE pattern for a literal term: its own wildcards are made literal too. */
|
|
1583
|
+
function likeLiteral(value: string): string {
|
|
1584
|
+
return `%${value.replace(/[\\%_]/g, char => `\\${char}`)}%`
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function sentSearchPrefilter(query: ParsedQuery): { clauses: string[]; args: unknown[] } {
|
|
1588
|
+
const clauses: string[] = []
|
|
1589
|
+
const args: unknown[] = []
|
|
1590
|
+
for (const group of query.groups) {
|
|
1591
|
+
const ors: string[] = []
|
|
1592
|
+
const groupArgs: unknown[] = []
|
|
1593
|
+
let expressible = true
|
|
1594
|
+
for (const term of group) {
|
|
1595
|
+
// SQLite's lower() folds ASCII only; leave such terms to the exact matcher.
|
|
1596
|
+
if (/[^\x00-\x7f]/.test(term.value)) { expressible = false; break }
|
|
1597
|
+
const column = term.field ? SENT_SEARCH_COLUMNS[term.field] : null
|
|
1598
|
+
if (column) {
|
|
1599
|
+
ors.push(`lower(coalesce(${column}, '')) ${term.negated ? 'NOT LIKE' : 'LIKE'} ? ESCAPE '\\'`)
|
|
1600
|
+
groupArgs.push(likeLiteral(term.value))
|
|
1601
|
+
continue
|
|
1602
|
+
}
|
|
1603
|
+
if (!term.field) {
|
|
1604
|
+
if (term.negated) { expressible = false; break }
|
|
1605
|
+
const columns = ['s.to_addrs', 's.cc', 's.from_addr', 's.subject', "substr(coalesce(s.body_text, ''), 1, 4000)"]
|
|
1606
|
+
ors.push(`(${columns.map(col => `lower(coalesce(${col}, '')) LIKE ? ESCAPE '\\'`).join(' OR ')})`)
|
|
1607
|
+
groupArgs.push(...columns.map(() => likeLiteral(term.value)))
|
|
1608
|
+
continue
|
|
1609
|
+
}
|
|
1610
|
+
if (term.field === 'before' || term.field === 'after') {
|
|
1611
|
+
const at = Date.parse(term.value)
|
|
1612
|
+
if (Number.isNaN(at)) { expressible = false; break }
|
|
1613
|
+
ors.push(`s.created_at ${term.field === 'before' ? '<' : '>'} ?`)
|
|
1614
|
+
groupArgs.push(new Date(at).toISOString())
|
|
1615
|
+
continue
|
|
1616
|
+
}
|
|
1617
|
+
if (term.field === 'older_than' || term.field === 'newer_than') {
|
|
1618
|
+
const span = duration(term.value)
|
|
1619
|
+
if (span == null) { expressible = false; break }
|
|
1620
|
+
ors.push(`s.created_at ${term.field === 'older_than' ? '<' : '>'} ?`)
|
|
1621
|
+
groupArgs.push(new Date(Date.now() - span).toISOString())
|
|
1622
|
+
continue
|
|
1623
|
+
}
|
|
1624
|
+
expressible = false
|
|
1625
|
+
break
|
|
1626
|
+
}
|
|
1627
|
+
if (expressible && ors.length) {
|
|
1628
|
+
clauses.push(`(${ors.join(' OR ')})`)
|
|
1629
|
+
args.push(...groupArgs)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return { clauses, args }
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* The sent archive for one mailbox, newest first. Unscoped, the newest 500 rows of the
|
|
1637
|
+
* whole table were read and then filtered by owner, so each person saw their share of the
|
|
1638
|
+
* company's last 500 sends — a quiet mailbox could see a dozen. The owner is matched on
|
|
1639
|
+
* the assignment in mail_sent_meta, else on the sender address; the caller still applies
|
|
1640
|
+
* its exact attribution on top.
|
|
1641
|
+
*
|
|
1642
|
+
* With a query, the search runs here over the whole archive instead of over whatever the
|
|
1643
|
+
* client happened to have loaded, and returns the body text the matcher needs.
|
|
1644
|
+
*/
|
|
1645
|
+
export async function readSentArchive(options: {
|
|
1646
|
+
ownerAddress?: string | null
|
|
1647
|
+
sharedAddress?: string | null
|
|
1648
|
+
query?: ParsedQuery | null
|
|
1649
|
+
limit?: number
|
|
1650
|
+
} = {}): Promise<SentMessage[]> {
|
|
1432
1651
|
await ensureMailSchema()
|
|
1433
1652
|
const sql = db()
|
|
1653
|
+
const limit = Math.min(Math.max(options.limit ?? 500, 1), 1000)
|
|
1654
|
+
const where: string[] = ['coalesce(m.is_auto, 0) = 0']
|
|
1655
|
+
const args: unknown[] = []
|
|
1656
|
+
|
|
1657
|
+
const owner = options.ownerAddress?.trim().toLowerCase()
|
|
1658
|
+
if (owner && owner !== options.sharedAddress?.toLowerCase()) {
|
|
1659
|
+
where.push(`(lower(m.owner) = ? OR ((m.owner IS NULL OR m.owner = '') AND lower(s.from_addr) LIKE ? ESCAPE '\\'))`)
|
|
1660
|
+
args.push(owner, likeLiteral(owner))
|
|
1661
|
+
}
|
|
1662
|
+
if (options.query && !options.query.isEmpty) {
|
|
1663
|
+
const prefilter = sentSearchPrefilter(options.query)
|
|
1664
|
+
where.push(...prefilter.clauses)
|
|
1665
|
+
args.push(...prefilter.args)
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1434
1668
|
// The list never shows a body, and 500 bodies is tens of megabytes — the Sent folder
|
|
1435
|
-
// took half a minute to open once an archive had been imported.
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1669
|
+
// took half a minute to open once an archive had been imported. A search needs some of
|
|
1670
|
+
// it to decide bare terms, so it gets the first few thousand characters and no more.
|
|
1671
|
+
const textColumn = options.query ? "substr(coalesce(s.body_text, ''), 1, 4000)" : 'NULL'
|
|
1672
|
+
const rows = await tagged(
|
|
1673
|
+
sql,
|
|
1674
|
+
`SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
|
|
1675
|
+
${textColumn} AS text
|
|
1676
|
+
FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
|
|
1677
|
+
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
1678
|
+
ORDER BY s.created_at DESC LIMIT ?`,
|
|
1679
|
+
[...args, limit],
|
|
1680
|
+
)
|
|
1439
1681
|
return rows.map(row => ({
|
|
1440
1682
|
id: String(row.id),
|
|
1441
1683
|
from: (row.from_addr as string) ?? '',
|
|
@@ -1445,7 +1687,7 @@ export async function readSentArchive(): Promise<SentMessage[]> {
|
|
|
1445
1687
|
replyTo: parseArray(row.reply_to),
|
|
1446
1688
|
subject: (row.subject as string) ?? '',
|
|
1447
1689
|
html: null,
|
|
1448
|
-
text: null,
|
|
1690
|
+
text: row.text == null ? null : String(row.text),
|
|
1449
1691
|
createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
|
|
1450
1692
|
lastEvent: (row.last_event as string) ?? null,
|
|
1451
1693
|
}))
|
|
@@ -1512,17 +1754,33 @@ export async function readSentMeta(emailIds?: string[]): Promise<Record<string,
|
|
|
1512
1754
|
// The caller only ever needs rows for the messages it is about to return. Unscoped,
|
|
1513
1755
|
// this read the whole table — thirty-odd thousand rows — on every poll of every tab,
|
|
1514
1756
|
// which is most of a month's read quota on its own.
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
const rows = await sql`SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta${scope}`
|
|
1757
|
+
// The sql tag binds every interpolation as a parameter; a spliced WHERE became `... ?`,
|
|
1758
|
+
// a syntax error the callers swallowed, so every lookup came back empty.
|
|
1518
1759
|
const out: Record<string, SentMeta> = {}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1760
|
+
const collect = (rows: Record<string, unknown>[]) => {
|
|
1761
|
+
for (const row of rows) {
|
|
1762
|
+
out[String(row.email_id)] = {
|
|
1763
|
+
owner: (row.owner as string) ?? null,
|
|
1764
|
+
isAuto: Boolean(row.is_auto),
|
|
1765
|
+
inReplyTo: (row.in_reply_to as string) ?? null,
|
|
1766
|
+
}
|
|
1524
1767
|
}
|
|
1525
1768
|
}
|
|
1769
|
+
if (!emailIds) {
|
|
1770
|
+
collect(await sql`SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta`)
|
|
1771
|
+
return out
|
|
1772
|
+
}
|
|
1773
|
+
for (let i = 0; i < emailIds.length; i += 200) {
|
|
1774
|
+
const chunk = emailIds.slice(i, i + 200)
|
|
1775
|
+
if (!chunk.length) break
|
|
1776
|
+
collect(
|
|
1777
|
+
await tagged(
|
|
1778
|
+
sql,
|
|
1779
|
+
`SELECT email_id, owner, is_auto, in_reply_to FROM mail_sent_meta WHERE email_id IN (${chunk.map(() => '?').join(',')})`,
|
|
1780
|
+
chunk,
|
|
1781
|
+
),
|
|
1782
|
+
)
|
|
1783
|
+
}
|
|
1526
1784
|
return out
|
|
1527
1785
|
}
|
|
1528
1786
|
|
|
@@ -1686,6 +1944,9 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
|
|
|
1686
1944
|
await sql`
|
|
1687
1945
|
INSERT INTO mail_accounts (email, password_hash, status, created_at) VALUES (${String(email)}, ${passwordHash}, 'active', ${nowIso()})
|
|
1688
1946
|
ON CONFLICT (email) DO UPDATE SET password_hash = excluded.password_hash, status = 'active'`
|
|
1947
|
+
// Any other link still outstanding for this address is now stale — and one of them
|
|
1948
|
+
// may be the reason the password is being changed.
|
|
1949
|
+
await sql`DELETE FROM mail_reset_tokens WHERE email = ${String(email)}`
|
|
1689
1950
|
return String(email)
|
|
1690
1951
|
}
|
|
1691
1952
|
|
|
@@ -1731,8 +1992,16 @@ export async function deleteStash(owner: string, kind: string, id: string): Prom
|
|
|
1731
1992
|
*/
|
|
1732
1993
|
const CLAIM_LEASE_MS = 10 * 60 * 1000
|
|
1733
1994
|
|
|
1734
|
-
|
|
1735
|
-
|
|
1995
|
+
/**
|
|
1996
|
+
* "busy" is the one that matters: an attempt is still inside its lease. The caller must
|
|
1997
|
+
* answer the provider with a failure, not a duplicate — five stranded deliveries on the
|
|
1998
|
+
* old host came from a process the platform killed mid-way, whose 5s and 5m retries were
|
|
1999
|
+
* each told "already handled" and so the provider never tried again.
|
|
2000
|
+
*/
|
|
2001
|
+
export type WebhookClaim = 'claimed' | 'done' | 'busy'
|
|
2002
|
+
|
|
2003
|
+
export async function claimWebhookEvent(id: string): Promise<WebhookClaim> {
|
|
2004
|
+
if (!id) return 'claimed'
|
|
1736
2005
|
await ensureMailSchema()
|
|
1737
2006
|
|
|
1738
2007
|
const inserted = await sqlRaw(
|
|
@@ -1741,19 +2010,19 @@ export async function claimWebhookEvent(id: string): Promise<boolean> {
|
|
|
1741
2010
|
RETURNING id`,
|
|
1742
2011
|
[id, nowIso()],
|
|
1743
2012
|
)
|
|
1744
|
-
if (inserted.length > 0) return
|
|
2013
|
+
if (inserted.length > 0) return 'claimed'
|
|
1745
2014
|
|
|
1746
2015
|
const existing = await sqlRaw('SELECT handled_at, status FROM mail_webhook_events WHERE id = ?', [id])
|
|
1747
2016
|
const row = existing[0]
|
|
1748
|
-
if (!row) return
|
|
1749
|
-
if (String(row.status) === 'done') return
|
|
2017
|
+
if (!row) return 'claimed'
|
|
2018
|
+
if (String(row.status) === 'done') return 'done'
|
|
1750
2019
|
|
|
1751
2020
|
const startedAt = new Date(String(row.handled_at)).getTime()
|
|
1752
|
-
if (Number.isFinite(startedAt) && Date.now() - startedAt < CLAIM_LEASE_MS) return
|
|
2021
|
+
if (Number.isFinite(startedAt) && Date.now() - startedAt < CLAIM_LEASE_MS) return 'busy'
|
|
1753
2022
|
|
|
1754
2023
|
// The attempt holding this never finished and its lease has run out. Take it over.
|
|
1755
2024
|
await sqlRaw('UPDATE mail_webhook_events SET handled_at = ? WHERE id = ?', [nowIso(), id])
|
|
1756
|
-
return
|
|
2025
|
+
return 'claimed'
|
|
1757
2026
|
}
|
|
1758
2027
|
|
|
1759
2028
|
/** Marks the delivery finished, so later redeliveries of it are refused for good. */
|