@novacraft-engineering/mailbox 0.2.1 → 0.4.2

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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Where a received message stops being new and starts repeating the thread.
3
+ *
4
+ * Mail clients append the entire earlier conversation under a reply, so a long thread
5
+ * carries the first message a dozen times over. The boundary is found, the repeat is
6
+ * folded away, and the reader is offered it rather than made to scroll past it.
7
+ *
8
+ * Every rule here was measured against the real mailbox, and the near misses matter more
9
+ * than the hits: a bare "From:" line fires on ordinary sentences a thousand times over, and
10
+ * a row of underscores is a decoration as often as a divider. Only the conjunctions are
11
+ * trusted — the divider AND the header block that must follow it.
12
+ */
13
+
14
+ const CONTAINER = [
15
+ // Outlook stacks a prefix onto the id on every requote, so match the suffix.
16
+ '[id$="divRplyFwdMsg" i]',
17
+ // Outlook Web draws this rule immediately above that div; every instance in the corpus
18
+ // sits on a real boundary, while a bare <hr> is decorative half the time.
19
+ 'hr[style*="inline-block"][style*="98%"]',
20
+ // Outlook desktop flattens the same boundary to a bordered div.
21
+ 'div[style*="border-top"][style*="#E1E1E1" i]',
22
+ '[class*="gmail_quote_container" i]',
23
+ 'blockquote[class*="gmail_quote" i]',
24
+ 'blockquote[type="cite" i]',
25
+ 'blockquote[id*="blockquote_zmail" i]',
26
+ '[class*="moz-cite-prefix" i]',
27
+ ].join(',')
28
+
29
+ // Deliberately absent: Outlook's empty "appendonsend" anchor. It marks where typing stopped,
30
+ // which looks like the ideal cut, but the sender's signature is sometimes emitted after it.
31
+
32
+ /** A reply divider: everything below it is the thread being repeated. */
33
+ const REPLY_DIVIDER = [
34
+ /^\s*On\b[\s\S]{10,220}?\bwrote:\s*$/i,
35
+ /^\s*-{4,}\s*On\b[\s\S]{10,220}?\bwrote\s*-{4,}\s*$/i,
36
+ ]
37
+
38
+ /**
39
+ * A forward marker means the opposite of a reply divider: what follows is not a repeat,
40
+ * it is the whole point of the message. Folding it on a bare forward would leave the
41
+ * reader a cover note and a fold line, so it is only honoured under a real covering note.
42
+ */
43
+ const FORWARD_MARKER = [
44
+ /^\s*-{2,20}\s*(?:Original Message|Original message|Forwarded message)\s*-{2,20}\s*$/i,
45
+ /^\s*Begin forwarded message:\s*$/i,
46
+ ]
47
+
48
+ /** A header block — only trusted when From: is followed by Sent:/Date:, never alone. */
49
+ const HEADER_BLOCK = /^\s*(?:\*\s*)?(?:From|De)\s*:\s*\S[\s\S]{0,400}?^\s*(?:\*\s*)?(?:Sent|Date|Enviado)\s*:/im
50
+
51
+ /** Text the reader must keep: if trimming leaves less than this, nothing is trimmed. */
52
+ const MIN_HEAD_CHARS = 25
53
+ /** A forward needs a substantial note of its own before its body may be folded. */
54
+ const MIN_HEAD_CHARS_FORWARD = 150
55
+
56
+ type Boundary = 'reply' | 'forward' | null
57
+
58
+ function quoteStart(element: Element): Boundary {
59
+ if (element.matches(CONTAINER)) return 'reply'
60
+ const text = (element.textContent ?? '').trim()
61
+ if (!text) return null
62
+ if (text.length < 400 && REPLY_DIVIDER.some(pattern => pattern.test(text))) return 'reply'
63
+ if (text.length < 400 && FORWARD_MARKER.some(pattern => pattern.test(text))) return 'forward'
64
+ // A divider drawn as a rule or a run of underscores only counts when a header block follows.
65
+ const looksLikeDivider = element.tagName === 'HR' || /^[_—-]{10,}$/.test(text)
66
+ if (looksLikeDivider) return null
67
+ return text.length < 1200 && HEADER_BLOCK.test(text) ? 'reply' : null
68
+ }
69
+
70
+ export type QuotedSplit = { head: string; tail: string | null }
71
+
72
+ /**
73
+ * Splits a message body into what was written now and what is being repeated.
74
+ * Returns tail = null when there is no repeat, or when trimming would leave nothing to read.
75
+ */
76
+ export function splitQuotedTail(html: string): QuotedSplit {
77
+ if (typeof window === 'undefined' || !html) return { head: html, tail: null }
78
+ let doc: Document
79
+ try {
80
+ doc = new DOMParser().parseFromString(html, 'text/html')
81
+ } catch {
82
+ return { head: html, tail: null }
83
+ }
84
+ const body = doc.body
85
+ if (!body) return { head: html, tail: null }
86
+
87
+ // Walk the outermost run of siblings, descending through wrapper divs that hold everything.
88
+ let scope: Element = body
89
+ for (let depth = 0; depth < 6; depth++) {
90
+ const elements = Array.from(scope.children)
91
+ if (elements.length !== 1 || !/^(div|table|tbody|tr|td|section|article)$/i.test(elements[0].tagName)) break
92
+ if (elements[0].matches(CONTAINER)) break
93
+ scope = elements[0]
94
+ }
95
+
96
+ const children = Array.from(scope.children)
97
+ let at = -1
98
+ let kind: Boundary = null
99
+ for (const [index, element] of children.entries()) {
100
+ const boundary = quoteStart(element)
101
+ if (boundary) { at = index; kind = boundary; break }
102
+ }
103
+ if (at <= 0) return { head: html, tail: null }
104
+
105
+ const head = children.slice(0, at)
106
+ const kept = head.map(element => element.textContent ?? '').join(' ').trim().length
107
+ if (kept < (kind === 'forward' ? MIN_HEAD_CHARS_FORWARD : MIN_HEAD_CHARS)) {
108
+ return { head: html, tail: null }
109
+ }
110
+ const tail = children.slice(at)
111
+ return {
112
+ head: head.map(element => element.outerHTML).join(''),
113
+ tail: tail.map(element => element.outerHTML).join(''),
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The same boundary in a message that arrived as plain text only.
119
+ *
120
+ * Half the bodies in the archive are text with no HTML part, and they quote just as
121
+ * heavily — so the reader needs this as much as the HTML path. Each pattern below was
122
+ * counted against the real archive, and the two that looked obvious are the two that
123
+ * had to be rejected: a lone "From:" heads every bounce report, and a row of underscores
124
+ * is a confidentiality footer more often than a divider.
125
+ */
126
+ const TEXT_REPLY = [
127
+ // A run of underscores, but only where the quoted headers actually follow it.
128
+ /(?:^|\r?\n)[ \t]*_{10,}[ \t]*(?:\r?\n)+(?=[ \t]*(?:>[ \t]*)*(?:From|De)[ \t]*:[ \t])/,
129
+ // The header block itself: From, then Sent or Date, then To or Cc.
130
+ /(?:^|\r?\n)(?:>[ \t]*)*(?:From|De)[ \t]*:[ \t][^\n]{1,400}(?:[ \t]*\r?\n)+(?:>[ \t]*)*(?:Sent|Date|Enviado)[ \t]*:[ \t][^\n]{1,200}(?:[ \t]*\r?\n)+(?:>[ \t]*)*(?:To|Cc|Para)[ \t]*:[ \t]/,
131
+ // The attribution sentence, allowed to wrap over a few lines.
132
+ /(?:^|\r?\n)[ \t>]*On\b(?:[^\n]*\n){0,3}?[^\n]*\bwrote:[ \t]*(?=\r?\n|$)/,
133
+ /(?:^|\r?\n)[ \t>]*-{4,}[ \t]*On\b[^\n]{0,300}?wrote[ \t]*-{4,}[ \t]*$/m,
134
+ ]
135
+
136
+ const TEXT_FORWARD = [
137
+ /(?:^|\r?\n)[ \t>]*-{2,20}[ \t]?(?:Original Message|Original message|Forwarded message)[ \t]?-{2,20}[ \t]*(?=\r?\n|$)/,
138
+ /(?:^|\r?\n)[ \t]*Begin forwarded message:[ \t]*(?=\r?\n|$)/,
139
+ ]
140
+
141
+ export type TextSplit = { head: string; tail: string | null }
142
+
143
+ export function splitQuotedText(text: string): TextSplit {
144
+ if (!text) return { head: text, tail: null }
145
+ let cut = -1
146
+ let kind: Boundary = null
147
+ for (const [patterns, boundary] of [[TEXT_REPLY, 'reply'], [TEXT_FORWARD, 'forward']] as const) {
148
+ for (const pattern of patterns) {
149
+ const match = pattern.exec(text)
150
+ if (!match) continue
151
+ // Cut at the divider itself, not after the newline that introduced it.
152
+ const at = match.index + (/^\r?\n/.test(match[0]) ? 1 : 0)
153
+ if (cut === -1 || at < cut) { cut = at; kind = boundary }
154
+ }
155
+ }
156
+ if (cut <= 0) return { head: text, tail: null }
157
+ const head = text.slice(0, cut)
158
+ if (head.trim().length < (kind === 'forward' ? MIN_HEAD_CHARS_FORWARD : MIN_HEAD_CHARS)) {
159
+ return { head: text, tail: null }
160
+ }
161
+ return { head: head.replace(/\s+$/, ''), tail: text.slice(cut) }
162
+ }
@@ -164,6 +164,56 @@ function matchTerm(term: Term, doc: SearchDoc): boolean {
164
164
  }
165
165
  }
166
166
 
167
+ export type ServerSearch = {
168
+ text: string
169
+ from?: string
170
+ to?: string
171
+ label?: string
172
+ unread?: boolean
173
+ starred?: boolean
174
+ hasAttachment?: boolean
175
+ }
176
+
177
+ /**
178
+ * The parts of a query the inbox route can answer itself go to the server; whatever it
179
+ * cannot express stays behind as the residual the browser still has to check.
180
+ */
181
+ export function splitForServer(query: ParsedQuery): { server: ServerSearch; residual: ParsedQuery } {
182
+ const server: ServerSearch = { text: '' }
183
+ const words: string[] = []
184
+ const residual: Term[][] = []
185
+ for (const group of query.groups) {
186
+ const term = group.length === 1 ? group[0] : null
187
+ if (!term || term.negated) {
188
+ residual.push(group)
189
+ continue
190
+ }
191
+ if (term.field === null) words.push(term.value)
192
+ else if (term.field === 'from' && !server.from) server.from = term.value
193
+ else if (term.field === 'to' && !server.to) server.to = term.value
194
+ else if (term.field === 'label' && !server.label) server.label = term.value
195
+ else if (term.field === 'is' && term.value === 'unread') server.unread = true
196
+ else if (term.field === 'is' && term.value === 'starred') server.starred = true
197
+ else if (term.field === 'has' && term.value === 'attachment') server.hasAttachment = true
198
+ else residual.push(group)
199
+ }
200
+ server.text = words.join(' ')
201
+ return { server, residual: { groups: residual, isEmpty: residual.length === 0 } }
202
+ }
203
+
204
+ export function serverSearchParams(raw: string): Record<string, string> {
205
+ const { server } = splitForServer(parseQuery(raw))
206
+ const params: Record<string, string> = {}
207
+ if (server.text) params.q = server.text
208
+ if (server.from) params.from = server.from
209
+ if (server.to) params.to = server.to
210
+ if (server.label) params.label = server.label
211
+ if (server.unread) params.unread = '1'
212
+ if (server.starred) params.starred = '1'
213
+ if (server.hasAttachment) params.attachment = '1'
214
+ return params
215
+ }
216
+
167
217
  export function matchesQuery(query: ParsedQuery, doc: SearchDoc): boolean {
168
218
  if (query.isEmpty) return true
169
219
  return query.groups.every(group =>
package/lib/mailbox.ts CHANGED
@@ -598,10 +598,10 @@ export async function invalidateCounts(owner: string | null | undefined): Promis
598
598
  }
599
599
  }
600
600
 
601
- export async function countFoldersCached(owner: string): Promise<FolderCounts> {
601
+ export async function countFoldersCached(owner: string | null): Promise<FolderCounts> {
602
602
  await ensureMailSchema()
603
603
  const sql = db()
604
- const key = owner.toLowerCase()
604
+ const key = owner === null ? '*all' : owner.toLowerCase()
605
605
  const hit = await sql`SELECT computed_at, counts FROM mail_counts_cache WHERE owner = ${key}`
606
606
  const row = hit[0]
607
607
  if (row && Date.now() - Date.parse(String(row.computed_at)) < COUNTS_CACHE_MS) {
@@ -610,7 +610,7 @@ export async function countFoldersCached(owner: string): Promise<FolderCounts> {
610
610
  // than serve a shape the caller will read as "threads are not live".
611
611
  if (parsed && 'conversations' in parsed) return parsed
612
612
  }
613
- const fresh = await countFolders(owner)
613
+ const fresh = await countFolders(owner ?? undefined)
614
614
  await sql`
615
615
  INSERT INTO mail_counts_cache (owner, computed_at, counts)
616
616
  VALUES (${key}, ${new Date().toISOString()}, ${JSON.stringify(fresh)})
@@ -836,9 +836,9 @@ export type ThreadFolder = 'inbox' | 'archive' | 'trash' | 'starred' | 'snoozed'
836
836
  /** The newest conversations in a folder: one row each, already summarised. */
837
837
  export type ThreadPage = { rows: ThreadRow[]; nextCursor: string | null }
838
838
 
839
- export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
839
+ export async function listThreads(ownerRaw: string | null, folder: ThreadFolder, limit: number, cursorRaw?: string | null): Promise<ThreadPage> {
840
840
  await ensureMailSchema()
841
- const owner = ownerRaw.toLowerCase()
841
+ const owner = ownerRaw === null ? null : ownerRaw.toLowerCase()
842
842
  const nowIso = new Date().toISOString()
843
843
  const predicate =
844
844
  folder === 'archive' ? 'archived_count > 0'
@@ -850,15 +850,31 @@ export async function listThreads(ownerRaw: string, folder: ThreadFolder, limit:
850
850
  // cursor's arguments have to follow whatever the predicate used.
851
851
  const folderArgs = folder === 'snoozed' || folder === 'inbox' ? [nowIso] : []
852
852
  const cursor = decodeCursor(cursorRaw)
853
- const rows = await tagged(db(), `
854
- SELECT thread_id, subject, first_at, latest_at, latest_id, count, unread_count, starred_count,
855
- inbox_count, archived_count, trashed_count, attach_count, senders, snippet, labels, snoozed_until
856
- FROM mail_threads WHERE owner = ? AND ${predicate}
857
- ${cursor ? 'AND (latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''}
858
- ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
859
- cursor
860
- ? [owner, ...folderArgs, cursor.receivedAt, cursor.receivedAt, cursor.id, limit]
861
- : [owner, ...folderArgs, limit])
853
+ const cursorClause = cursor ? '(latest_at < ? OR (latest_at = ? AND thread_id < ?))' : ''
854
+ const cursorArgs = cursor ? [cursor.receivedAt, cursor.receivedAt, cursor.id] : []
855
+ // Thread rows are materialised per owner, so reading every account has to fold the same
856
+ // conversation back together. SQLite fills a bare column from whichever row matched the
857
+ // MAX in the same select, which is how subject, snippet and senders come from the latest
858
+ // message rather than an arbitrary one.
859
+ const rows = owner === null
860
+ ? await tagged(db(), `
861
+ SELECT thread_id, subject, MIN(first_at) AS first_at, MAX(latest_at) AS latest_at, latest_id,
862
+ SUM(count) AS count, SUM(unread_count) AS unread_count, SUM(starred_count) AS starred_count,
863
+ 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
866
+ FROM mail_threads
867
+ GROUP BY thread_id
868
+ HAVING ${predicate}${cursorClause ? ` AND ${cursorClause}` : ''}
869
+ ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
870
+ [...folderArgs, ...cursorArgs, limit])
871
+ : await tagged(db(), `
872
+ 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
874
+ FROM mail_threads WHERE owner = ? AND ${predicate}
875
+ ${cursorClause ? `AND ${cursorClause}` : ''}
876
+ ORDER BY latest_at DESC, thread_id DESC LIMIT ?`,
877
+ [owner, ...folderArgs, ...cursorArgs, limit])
862
878
  const last = rows[rows.length - 1]
863
879
  const nextCursor = rows.length === limit && last ? encodeCursor(String(last.latest_at), String(last.thread_id)) : null
864
880
  const mapped = rows.map(row => ({
@@ -1551,6 +1567,8 @@ export type SentMessage = {
1551
1567
  lastEvent?: string | null
1552
1568
  /** The bucket keys that went out with it, so a forward has something of ours to copy. */
1553
1569
  attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
1570
+ /** How many files the list should raise a paperclip for, counted the way the inbox counts. */
1571
+ attachmentCount?: number
1554
1572
  }
1555
1573
 
1556
1574
  export async function recordSentMessage(message: SentMessage): Promise<void> {
@@ -1672,6 +1690,7 @@ export async function readSentArchive(options: {
1672
1690
  const rows = await tagged(
1673
1691
  sql,
1674
1692
  `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
+ json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
1675
1694
  ${textColumn} AS text
1676
1695
  FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
1677
1696
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
@@ -1690,6 +1709,7 @@ export async function readSentArchive(options: {
1690
1709
  text: row.text == null ? null : String(row.text),
1691
1710
  createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1692
1711
  lastEvent: (row.last_event as string) ?? null,
1712
+ attachmentCount: Number(row.attach_count ?? 0),
1693
1713
  }))
1694
1714
  }
1695
1715
 
package/lib/scope.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { MailRole } from './brand'
2
+
3
+ const ALL_INBOXES = (process.env.MAIL_ALL_INBOXES_ADDRESS ?? '').trim().toLowerCase()
4
+
5
+ export type Viewer = { address: string | null; role: MailRole }
6
+
7
+ /**
8
+ * One address per deployment may read every account's mail. Naming the address rather
9
+ * than granting it to the admin role keeps the two powers apart: administering accounts
10
+ * still does not carry the right to read someone else's inbox.
11
+ */
12
+ export function readsAllInboxes(viewer: Viewer): boolean {
13
+ if (!ALL_INBOXES) return false
14
+ if (viewer.role !== 'admin') return false
15
+ return (viewer.address ?? '').toLowerCase() === ALL_INBOXES
16
+ }
17
+
18
+ /** The owner to filter reads by, or null for every account. */
19
+ export function inboxScope(viewer: Viewer): string | null {
20
+ if (readsAllInboxes(viewer)) return null
21
+ return viewer.address ?? ' no-address'
22
+ }
23
+
24
+ /**
25
+ * Which inbox a request should read. An address the viewer may not see is not an error:
26
+ * it falls back to their own, so a guessed parameter reveals nothing.
27
+ */
28
+ export function scopeFor(viewer: Viewer, requested?: string | null): string | null {
29
+ const own = viewer.address ?? ' no-address'
30
+ const wanted = (requested ?? '').trim().toLowerCase()
31
+ const everything = readsAllInboxes(viewer)
32
+ if (!wanted || wanted === 'all') return everything ? null : own
33
+ return everything ? wanted : own
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.2.1",
3
+ "version": "0.4.2",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",
@@ -38,7 +38,7 @@
38
38
  "lint": "eslint ."
39
39
  },
40
40
  "dependencies": {
41
- "@libsql/client": "0.17.0",
41
+ "@libsql/client": "0.18.0",
42
42
  "@tiptap/extension-color": "^3.31.3",
43
43
  "@tiptap/extension-highlight": "^3.31.3",
44
44
  "@tiptap/extension-image": "^3.31.3",
@@ -50,10 +50,9 @@
50
50
  "@tiptap/pm": "^3.31.3",
51
51
  "@tiptap/react": "^3.31.3",
52
52
  "@tiptap/starter-kit": "^3.31.3",
53
- "@vercel/blob": "^2.6.1",
54
53
  "next": "^16.3.5",
55
- "react": "19.2.4",
56
- "react-dom": "19.2.4",
54
+ "react": "19.3.0",
55
+ "react-dom": "19.3.0",
57
56
  "resend": "^6.12.3",
58
57
  "web-push": "^3.6.7"
59
58
  },
@@ -62,8 +61,8 @@
62
61
  "@types/react": "^19",
63
62
  "@types/react-dom": "^19",
64
63
  "@types/web-push": "^3.6.4",
65
- "eslint": "^9.39.5",
64
+ "eslint": "9.39.5",
66
65
  "eslint-config-next": "^16.2.6",
67
- "typescript": "^5"
66
+ "typescript": "6.0.3"
68
67
  }
69
68
  }
@@ -23,6 +23,7 @@ import os
23
23
  import re
24
24
  import socket
25
25
  import sys
26
+ import sqlite3
26
27
  import threading
27
28
  import time
28
29
  import urllib.error
@@ -73,7 +74,7 @@ INBOX_COLUMNS = ('id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, bod
73
74
  INSERT_INBOX = (f'INSERT INTO mail_inbox ({INBOX_COLUMNS}) VALUES ({",".join("?" * 18)}) '
74
75
  'ON CONFLICT (id) DO NOTHING')
75
76
  INSERT_SENT = ('INSERT INTO mail_sent (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, '
76
- 'created_at, last_event, provider) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (id) DO NOTHING')
77
+ 'created_at, last_event, provider, attachments) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (id) DO NOTHING')
77
78
  INSERT_SENT_META = ('INSERT INTO mail_sent_meta (email_id, owner, is_auto, created_at) VALUES (?,?,0,?) '
78
79
  'ON CONFLICT (email_id) DO NOTHING')
79
80
 
@@ -332,20 +333,23 @@ STORE = 'blob'
332
333
 
333
334
 
334
335
  def stored_attachments(url, token, ids):
335
- """id -> (owner, attachments) for inbox rows already stored."""
336
+ """id -> (owner, attachments, table) for rows already stored, in either table."""
336
337
  if not ids:
337
338
  return {}
338
339
  marks = ','.join('?' * len(ids))
339
340
  results = execute(url, token, [
340
341
  {'sql': f'SELECT id, owner, attachments FROM mail_inbox WHERE id IN ({marks})', 'args': [arg(i) for i in ids]},
342
+ {'sql': f'SELECT s.id, m.owner, s.attachments FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id '
343
+ f'WHERE s.id IN ({marks})', 'args': [arg(i) for i in ids]},
341
344
  ])
342
345
  found = {}
343
- for row in results[0]['response']['result']['rows']:
344
- try:
345
- entries = json.loads(row[2]['value'] or '[]')
346
- except Exception:
347
- entries = []
348
- found[row[0]['value']] = ((row[1]['value'] or '').lower(), entries)
346
+ for table, result in zip(('mail_inbox', 'mail_sent'), results[:2]):
347
+ for row in result['response']['result']['rows']:
348
+ try:
349
+ entries = json.loads(row[2]['value'] or '[]')
350
+ except Exception:
351
+ entries = []
352
+ found[row[0]['value']] = ((row[1]['value'] or '').lower(), entries, table)
349
353
  return found
350
354
 
351
355
 
@@ -356,9 +360,26 @@ def arg(value, kind='text'):
356
360
 
357
361
 
358
362
  def execute(url, token, statements):
363
+ if url.startswith('file:'):
364
+ return execute_sqlite(url[len('file:'):], statements)
359
365
  return with_retry(lambda: execute_once(url, token, statements), 'turso')
360
366
 
361
367
 
368
+ SQLITE_LOCK = threading.Lock()
369
+
370
+
371
+ def execute_sqlite(path, statements):
372
+ """The pipeline's shape over a local SQLite file, for a database that is not served over HTTP."""
373
+ with SQLITE_LOCK, sqlite3.connect(path, timeout=120) as connection:
374
+ connection.execute('PRAGMA busy_timeout = 120000')
375
+ results = []
376
+ for statement in statements:
377
+ cursor = connection.execute(statement['sql'], [a['value'] for a in statement['args']])
378
+ rows = cursor.fetchall() if cursor.description else []
379
+ results.append({'response': {'result': {'rows': [[{'value': cell} for cell in row] for row in rows]}}})
380
+ return results
381
+
382
+
362
383
  def execute_once(url, token, statements):
363
384
  requests = [{'type': 'execute', 'stmt': stmt} for stmt in statements] + [{'type': 'close'}]
364
385
  request = urllib.request.Request(
@@ -409,6 +430,7 @@ def sent_statements(row):
409
430
  arg(row['id']), arg(row['from']), arg(json.dumps(row['to'])), arg(json.dumps(row['cc'])),
410
431
  arg(json.dumps(row['bcc'])), arg(json.dumps(row['replyTo'])), arg(row['subject']),
411
432
  arg(row['html']), arg(row['text']), arg(row['receivedAt']), arg('imported'), arg('mbox'),
433
+ arg(json.dumps(row['attachments'])),
412
434
  ]},
413
435
  {'sql': INSERT_SENT_META, 'args': [arg(row['id']), arg(row['owner']), arg(row['receivedAt'])]},
414
436
  ]
@@ -424,6 +446,7 @@ def main():
424
446
  parser.add_argument('--batch', type=int, default=10)
425
447
  parser.add_argument('--mark-read', action='store_true')
426
448
  parser.add_argument('--skip-attachments', action='store_true')
449
+ parser.add_argument('--kinds', default='inbox,sent', help='which rows the attachment pass touches: inbox, sent, or both')
427
450
  parser.add_argument('--attach-only', action='store_true',
428
451
  help='second pass: upload attachments for rows already imported and write the URLs back')
429
452
  parser.add_argument('--workers', type=int, default=8, help='parallel attachment uploads')
@@ -450,8 +473,9 @@ def main():
450
473
  print(f'upload cap: {args.max_mbps} Mbit/s', flush=True)
451
474
  url, db_token = os.environ.get('TURSO_DATABASE_URL'), os.environ.get('TURSO_AUTH_TOKEN')
452
475
  blob_token = None if args.skip_attachments else (os.environ.get('BLOB_READ_WRITE_TOKEN') if STORE == 'blob' else 's3')
453
- if not args.dry_run and not (url and db_token):
476
+ if not args.dry_run and not (url and (db_token or url.startswith('file:'))):
454
477
  sys.exit('TURSO_DATABASE_URL and TURSO_AUTH_TOKEN must be set')
478
+ kinds = {kind.strip() for kind in args.kinds.split(',') if kind.strip()}
455
479
  if not args.dry_run and not args.skip_attachments and not blob_token:
456
480
  print('BLOB_READ_WRITE_TOKEN unset: attachments will be metadata only', flush=True)
457
481
 
@@ -477,6 +501,7 @@ def main():
477
501
  local['missing'] += 1
478
502
  continue
479
503
  row['id'] = mine
504
+ row['table'] = stored[mine][2]
480
505
  entries = stored[mine][1]
481
506
  want = len(row['attachments'])
482
507
  complete = lambda e: bool(e.get('key')) or (bool(e.get('url')) and not args.replace_blob)
@@ -524,7 +549,7 @@ def main():
524
549
  touched.add(row['id'])
525
550
  for row in rows:
526
551
  if row['id'] in touched or row.get('reuse'):
527
- execute(url, db_token, [{'sql': 'UPDATE mail_inbox SET attachments = ? WHERE id = ?',
552
+ execute(url, db_token, [{'sql': f'UPDATE {row["table"]} SET attachments = ? WHERE id = ?',
528
553
  'args': [arg(json.dumps(row['stored'])), arg(row['id'])]}])
529
554
  local['updated'] += 1
530
555
  for attachment in row['attachments']:
@@ -659,7 +684,7 @@ def main():
659
684
  print(f' {row["receivedAt"][:10]} {row["kind"]:5} {flag} {row["from"][:30]:30} {row["subject"][:44]}'
660
685
  f' [{len(row["attachments"])} files]', flush=True)
661
686
  if args.attach_only:
662
- if row['kind'] != 'inbox' or not row['attachments']:
687
+ if row['kind'] not in kinds or not row['attachments']:
663
688
  continue
664
689
  chunk.append(row)
665
690
  if len(chunk) >= args.batch:
@@ -1,43 +0,0 @@
1
- import { NextResponse } from 'next/server'
2
- import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
3
- import { authenticate, isLocalOrigin, verifyMailAuth } from '@/lib/dev-auth'
4
-
5
- export const runtime = 'nodejs'
6
-
7
- const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
8
-
9
- export async function POST(req: Request): Promise<NextResponse> {
10
- const body = (await req.json()) as HandleUploadBody
11
-
12
- try {
13
- const result = await handleUpload({
14
- body,
15
- request: req,
16
- onBeforeGenerateToken: async (_pathname, clientPayload) => {
17
- // The session cookie is the normal path; the credentials in clientPayload are only
18
- // still read so a tab that signed in before sessions existed can finish its upload.
19
- let authorized = isLocalOrigin(req) || Boolean(await authenticate(req))
20
- if (!authorized && clientPayload) {
21
- try {
22
- const creds = JSON.parse(clientPayload) as { email?: string; password?: string }
23
- authorized = (await verifyMailAuth(creds.email ?? '', creds.password ?? '')).ok
24
- } catch {
25
- authorized = false
26
- }
27
- }
28
- if (!authorized) throw new Error('Unauthorized')
29
- return {
30
- addRandomSuffix: true,
31
- maximumSizeInBytes: MAX_UPLOAD_BYTES,
32
- tokenPayload: null,
33
- }
34
- },
35
- onUploadCompleted: async () => {
36
- // No-op: the URL is returned to the client, which attaches it on send.
37
- },
38
- })
39
- return NextResponse.json(result)
40
- } catch (error) {
41
- return NextResponse.json({ error: (error as Error).message }, { status: 400 })
42
- }
43
- }