@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.
- package/app/api/mail/emails/route.ts +7 -5
- package/app/api/mail/inbox/counts/route.ts +6 -5
- package/app/api/mail/inbox/route.ts +6 -5
- package/app/api/mail/mailboxes/route.ts +27 -0
- package/app/api/mail/threads/route.ts +2 -1
- package/app/mail/MailSelect.tsx +11 -2
- package/app/mail/Ticker.tsx +34 -2
- package/app/mail/page.module.css +123 -1
- package/app/mail/page.tsx +361 -88
- package/app/mail/quoted.ts +162 -0
- package/app/mail/search.ts +50 -0
- package/lib/mailbox.ts +34 -14
- package/lib/scope.ts +34 -0
- package/package.json +6 -7
- 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
|
@@ -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
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
|
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.
|
|
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.
|
|
56
|
-
"react-dom": "19.
|
|
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": "
|
|
64
|
+
"eslint": "9.39.5",
|
|
66
65
|
"eslint-config-next": "^16.2.6",
|
|
67
|
-
"typescript": "
|
|
66
|
+
"typescript": "6.0.3"
|
|
68
67
|
}
|
|
69
68
|
}
|
|
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
|
-
}
|