@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.
@@ -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
@@ -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.2.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",
@@ -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
- }