@novacraft-engineering/mailbox 0.2.0 → 0.3.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/app/api/mail/emails/route.ts +3 -2
- package/app/api/mail/inbox/route.ts +1 -1
- package/app/mail/MailSelect.tsx +21 -13
- package/app/mail/RichEditor.tsx +11 -13
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +106 -12
- package/app/mail/page.tsx +259 -82
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +4 -0
- package/package.json +1 -2
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
- package/tools/import-mbox.py +36 -11
- package/app/api/mail/upload/route.ts +0 -43
|
@@ -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
|
+
}
|
package/app/mail/search.ts
CHANGED
|
@@ -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
|
@@ -1551,6 +1551,8 @@ export type SentMessage = {
|
|
|
1551
1551
|
lastEvent?: string | null
|
|
1552
1552
|
/** The bucket keys that went out with it, so a forward has something of ours to copy. */
|
|
1553
1553
|
attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
|
|
1554
|
+
/** How many files the list should raise a paperclip for, counted the way the inbox counts. */
|
|
1555
|
+
attachmentCount?: number
|
|
1554
1556
|
}
|
|
1555
1557
|
|
|
1556
1558
|
export async function recordSentMessage(message: SentMessage): Promise<void> {
|
|
@@ -1672,6 +1674,7 @@ export async function readSentArchive(options: {
|
|
|
1672
1674
|
const rows = await tagged(
|
|
1673
1675
|
sql,
|
|
1674
1676
|
`SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
|
|
1677
|
+
json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
|
|
1675
1678
|
${textColumn} AS text
|
|
1676
1679
|
FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
|
|
1677
1680
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
@@ -1690,6 +1693,7 @@ export async function readSentArchive(options: {
|
|
|
1690
1693
|
text: row.text == null ? null : String(row.text),
|
|
1691
1694
|
createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
|
|
1692
1695
|
lastEvent: (row.last_event as string) ?? null,
|
|
1696
|
+
attachmentCount: Number(row.attach_count ?? 0),
|
|
1693
1697
|
}))
|
|
1694
1698
|
}
|
|
1695
1699
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -50,7 +50,6 @@
|
|
|
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
54
|
"react": "19.2.4",
|
|
56
55
|
"react-dom": "19.2.4",
|
|
Binary file
|
package/tools/import-mbox.py
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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']
|
|
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
|
-
}
|