@novacraft-engineering/mailbox 0.1.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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
package/lib/turso.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Turso (libSQL) over HTTP, exposing the same tagged-template shape the D1 client used
3
+ * so every call site in `mailbox.ts` reads unchanged. libSQL is SQLite, so the schema
4
+ * and its `ON CONFLICT` clauses port without translation.
5
+ *
6
+ * HTTP mode rather than WebSocket: Vercel functions are short-lived and a socket per
7
+ * invocation is churn we would only pay for.
8
+ */
9
+
10
+ import { createClient, type Client, type InValue } from '@libsql/client'
11
+
12
+ export type SqlTag = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>
13
+
14
+ let cached: Client | null = null
15
+
16
+ function client(): Client {
17
+ if (cached) return cached
18
+ const url = process.env.TURSO_DATABASE_URL
19
+ const authToken = process.env.TURSO_AUTH_TOKEN
20
+ if (!url) throw new Error('TURSO_DATABASE_URL must be configured')
21
+ // Embedded replicas and file: URLs need no token; a remote libsql:// URL does.
22
+ cached = createClient({ url, ...(authToken ? { authToken } : {}) })
23
+ return cached
24
+ }
25
+
26
+ /** SQLite has no boolean or object types — normalise before binding. */
27
+ function toParam(value: unknown): InValue {
28
+ if (value === undefined || value === null) return null
29
+ if (typeof value === 'boolean') return value ? 1 : 0
30
+ if (typeof value === 'number' || typeof value === 'bigint') return value
31
+ if (value instanceof Date) return value.toISOString()
32
+ if (value instanceof Uint8Array) return value
33
+ if (typeof value === 'object') return JSON.stringify(value)
34
+ return String(value)
35
+ }
36
+
37
+ export async function tursoQuery(sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
38
+ const result = await client().execute({ sql, args: params.map(toParam) })
39
+ return result.rows as unknown as Record<string, unknown>[]
40
+ }
41
+
42
+ /** Tagged-template entry point: interpolations become bound `?` parameters. */
43
+ export function turso(): SqlTag {
44
+ return (strings, ...values) => tursoQuery(strings.raw.join('?'), values)
45
+ }
46
+
47
+ /**
48
+ * Run several statements as one transaction. The D1 client could only loop, so a
49
+ * half-applied schema was possible; libSQL gives us the real thing.
50
+ */
51
+ export async function tursoBatch(statements: string[]): Promise<void> {
52
+ await client().batch(statements.map(sql => ({ sql, args: [] })), 'write')
53
+ }
package/next.config.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ /**
4
+ * This is a private mailbox. Nothing here should ever be indexed, summarised or
5
+ * used as training data, so the refusal is sent as a header too — a crawler that
6
+ * ignores robots.txt still receives it on every response.
7
+ */
8
+ const noIndexHeaders = [
9
+ { key: 'X-Robots-Tag', value: 'noindex, nofollow, noarchive, nosnippet, noimageindex, notranslate' },
10
+ { key: 'X-Frame-Options', value: 'DENY' },
11
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
12
+ { key: 'Referrer-Policy', value: 'no-referrer' },
13
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
14
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
15
+ { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
16
+ ];
17
+
18
+ const nextConfig: NextConfig = {
19
+ // Ensure the contact-form email templates ship with the serverless bundle.
20
+ // Without this, `readFileSync('lib/emails/templates/*.html')` works in dev
21
+ // but the .html files get tree-shaken out of the production trace.
22
+ outputFileTracingIncludes: {
23
+ '/api/contact': ['./lib/emails/templates/**/*.html'],
24
+ },
25
+ poweredByHeader: false,
26
+ env: {
27
+ // Captured at build time. On Vercel this is the last successful build's
28
+ // ISO timestamp; locally it's the time `next build` ran.
29
+ NEXT_PUBLIC_BUILD_TIME: new Date().toISOString(),
30
+ },
31
+ async headers() {
32
+ return [
33
+ { source: '/:path*', headers: noIndexHeaders },
34
+ // The viewer shows a PDF in an iframe pointed at our own route, and DENY refuses
35
+ // that as firmly as it refuses a stranger — the browser reports it as the site
36
+ // refusing to connect, which reads as the file being unreachable. These paths only
37
+ // ever return an attachment, and only to a signed-in caller, so they allow being
38
+ // framed by this origin and nothing else.
39
+ {
40
+ source: '/api/mail/inbox/attachments/download',
41
+ headers: [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }],
42
+ },
43
+ {
44
+ source: '/api/mail/emails/:id/attachments/download',
45
+ headers: [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }],
46
+ },
47
+ ];
48
+ },
49
+ };
50
+
51
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@novacraft-engineering/mailbox",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "webmail",
7
+ "email",
8
+ "mailbox",
9
+ "nextjs",
10
+ "sqlite",
11
+ "self-hosted"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Jaybee4real/mailbox.git"
16
+ },
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "app",
23
+ "lib",
24
+ "public",
25
+ "scripts",
26
+ "tools",
27
+ "next.config.ts",
28
+ "tsconfig.json",
29
+ "eslint.config.mjs",
30
+ ".env.example",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "dev": "next dev -p $(perch port 2>/dev/null || echo 4030)",
36
+ "build": "next build",
37
+ "start": "next start -p 4030",
38
+ "lint": "eslint ."
39
+ },
40
+ "dependencies": {
41
+ "@libsql/client": "0.17.0",
42
+ "@tiptap/extension-color": "^3.31.3",
43
+ "@tiptap/extension-highlight": "^3.31.3",
44
+ "@tiptap/extension-image": "^3.31.3",
45
+ "@tiptap/extension-link": "^3.31.3",
46
+ "@tiptap/extension-table": "^3.31.3",
47
+ "@tiptap/extension-text-align": "^3.31.3",
48
+ "@tiptap/extension-text-style": "^3.31.3",
49
+ "@tiptap/extension-underline": "^3.31.3",
50
+ "@tiptap/pm": "^3.31.3",
51
+ "@tiptap/react": "^3.31.3",
52
+ "@tiptap/starter-kit": "^3.31.3",
53
+ "@vercel/blob": "^2.6.1",
54
+ "next": "16.2.6",
55
+ "react": "19.2.4",
56
+ "react-dom": "19.2.4",
57
+ "resend": "^6.12.3",
58
+ "web-push": "^3.6.7"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^22",
62
+ "@types/react": "^19",
63
+ "@types/react-dom": "^19",
64
+ "@types/web-push": "^3.6.4",
65
+ "eslint": "^9.39.5",
66
+ "eslint-config-next": "^16.2.6",
67
+ "typescript": "^5"
68
+ }
69
+ }
@@ -0,0 +1,35 @@
1
+ # Brand assets
2
+
3
+ > **Never deploy or `git push` unless asked for it in that request.** Pushing
4
+ > `master` auto-deploys every live mailbox. See the deploying section in the
5
+ > root [README](../README.md).
6
+
7
+ **No tenant's artwork belongs in this repository.** Each mailbox keeps its own
8
+ images on its mounted volume, in the directory named by `BRAND_ASSET_DIR`
9
+ (default `/data/brand`), and the app serves them from `/brand/<file>`.
10
+
11
+ | File | Used by | Notes |
12
+ |---|---|---|
13
+ | `mark.png` | app chrome | Drawn through a CSS mask, so only the alpha channel matters. |
14
+ | `mark-email.png` | email signatures | Must carry real colour — mail clients cannot apply a CSS mask. |
15
+ | `logo.png` | share pages | Full lockup. |
16
+
17
+ Point the configuration at them:
18
+
19
+ ```
20
+ BRAND_MARK_URL=https://mail.example.com/brand/mark-email.png
21
+ NEXT_PUBLIC_BRAND_MARK_URL=https://mail.example.com/brand/mark-email.png
22
+ BRAND_CHROME_MARK_URL=/brand/mark.png
23
+ NEXT_PUBLIC_BRAND_CHROME_MARK_URL=/brand/mark.png
24
+ ```
25
+
26
+ Any of these may instead be a URL on a host the tenant already owns; nothing in
27
+ the app requires the files to be served from here.
28
+
29
+ ## App icons
30
+
31
+ `public/icon-192.png`, `public/icon-512.png`, `public/icon-maskable-512.png`,
32
+ `app/icon.png` and `app/apple-icon.png` ship as a neutral placeholder — a plain
33
+ envelope on the default accent. They are the favicon, the Apple touch icon and
34
+ the icon a PWA install uses. `BRAND_ICON_URL` and `BRAND_APPLE_ICON_URL`
35
+ override them per tenant without touching the repository.
Binary file
Binary file
Binary file
package/public/sw.js ADDED
@@ -0,0 +1,41 @@
1
+ self.addEventListener('install', () => self.skipWaiting())
2
+ self.addEventListener('activate', event => event.waitUntil(self.clients.claim()))
3
+
4
+ // Present but deliberately does not respond: installability needs a fetch handler, and a
5
+ // mailbox must never be served from a cache. Every request falls through to the network.
6
+ self.addEventListener('fetch', () => {})
7
+
8
+ self.addEventListener('notificationclick', event => {
9
+ event.notification.close()
10
+ event.waitUntil(
11
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
12
+ const open = clients.find(client => client.url.includes('/mail'))
13
+ if (open) return open.focus()
14
+ return self.clients.openWindow(event.notification.data?.url || '/mail')
15
+ }),
16
+ )
17
+ })
18
+
19
+ // Web Push, sent by the inbound webhook for the mailbox this device registered.
20
+ self.addEventListener('push', event => {
21
+ let payload = {}
22
+ try {
23
+ payload = event.data ? event.data.json() : {}
24
+ } catch {
25
+ payload = { title: 'New mail', body: event.data ? event.data.text() : '' }
26
+ }
27
+ event.waitUntil(
28
+ Promise.all([
29
+ self.registration.showNotification(payload.title || 'New mail', {
30
+ body: payload.body || '',
31
+ icon: '/icon-192.png',
32
+ badge: '/icon-192.png',
33
+ tag: payload.tag || 'mailbox',
34
+ data: { url: payload.url || '/mail' },
35
+ }),
36
+ self.clients
37
+ .matchAll({ type: 'window', includeUncontrolled: true })
38
+ .then(clients => clients.forEach(client => client.postMessage({ type: 'mail:new' }))),
39
+ ]),
40
+ )
41
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Copies attachment bytes out of Resend and into our bucket, for messages received
3
+ * before the app started keeping its own copy.
4
+ *
5
+ * The work happens on the deployment, not here: this only drives it, so the files
6
+ * travel between the provider and the bucket inside one region instead of down to
7
+ * whatever connection is running the script.
8
+ *
9
+ * Resumable — a message is picked up only while it has no stored key, so stopping
10
+ * and re-running costs nothing.
11
+ *
12
+ * MAINTENANCE_TOKEN=… node scripts/backfill-attachments.mjs [--limit 25] [--host https://…]
13
+ */
14
+
15
+ const args = process.argv.slice(2)
16
+ const flag = (name, fallback) => {
17
+ const at = args.indexOf(`--${name}`)
18
+ return at === -1 ? fallback : args[at + 1]
19
+ }
20
+
21
+ const HOST = flag('host', 'https://mail.example.com')
22
+ const LIMIT = Number(flag('limit', 25))
23
+ const TOKEN = process.env.MAINTENANCE_TOKEN
24
+
25
+ if (!TOKEN) {
26
+ console.error('MAINTENANCE_TOKEN is not set.')
27
+ process.exit(1)
28
+ }
29
+
30
+ const readable = bytes => {
31
+ const units = ['B', 'KB', 'MB', 'GB']
32
+ let value = bytes
33
+ let unit = 0
34
+ while (value >= 1024 && unit < units.length - 1) {
35
+ value /= 1024
36
+ unit += 1
37
+ }
38
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`
39
+ }
40
+
41
+ const call = async query => {
42
+ const response = await fetch(`${HOST}/api/mail/maintenance/attachments?${query}`, {
43
+ method: 'POST',
44
+ headers: { authorization: `Bearer ${TOKEN}` },
45
+ })
46
+ const text = await response.text()
47
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`)
48
+ return JSON.parse(text)
49
+ }
50
+
51
+ const started = Date.now()
52
+ let copied = 0
53
+ let files = 0
54
+ let bytes = 0
55
+ let failed = 0
56
+
57
+ const first = await call(`limit=1&count=1`)
58
+ const outstanding = first.remaining ?? 0
59
+ copied += first.copied
60
+ files += first.files
61
+ bytes += first.bytes
62
+ console.log(` ${outstanding} message(s) still to copy\n`)
63
+
64
+ for (let round = 1; outstanding > 0 && round <= 5000; round += 1) {
65
+ let result
66
+ try {
67
+ result = await call(`limit=${LIMIT}`)
68
+ } catch (err) {
69
+ console.log(` round ${round}: ${err.message} — retrying in 10s`)
70
+ await new Promise(resolve => setTimeout(resolve, 10_000))
71
+ continue
72
+ }
73
+
74
+ if (result.scanned === 0) {
75
+ console.log('\n nothing left to copy')
76
+ break
77
+ }
78
+
79
+ copied += result.copied
80
+ files += result.files
81
+ bytes += result.bytes
82
+ failed += result.failed
83
+
84
+ const elapsed = (Date.now() - started) / 1000
85
+ const rate = copied / Math.max(elapsed, 1)
86
+ const left = Math.max(outstanding - copied, 0)
87
+ const eta = rate > 0 ? Math.round(left / rate) : 0
88
+ process.stdout.write(
89
+ `\r ${copied}/${outstanding} messages · ${files} files · ${readable(bytes)}` +
90
+ `${failed ? ` · ${failed} failed` : ''} · ~${Math.floor(eta / 60)}m ${eta % 60}s left `,
91
+ )
92
+ }
93
+
94
+ const total = Math.round((Date.now() - started) / 1000)
95
+ console.log(`\n\n done in ${Math.floor(total / 60)}m ${total % 60}s`)
96
+ console.log(` ${copied} messages, ${files} files, ${readable(bytes)} copied${failed ? `, ${failed} failed` : ''}`)
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Records the Content-ID of each stored file, which is what tells an embedded
3
+ * signature image apart from something the sender actually attached.
4
+ *
5
+ * Metadata only — the bytes are already in the bucket, so this moves nothing and
6
+ * costs one provider call per message.
7
+ *
8
+ * node scripts/backfill-content-ids.mjs [--batch 40]
9
+ */
10
+
11
+ import { createClient } from '@libsql/client'
12
+ import { readFileSync } from 'node:fs'
13
+
14
+ for (const line of readFileSync(new URL('../.env.local', import.meta.url), 'utf8').split('\n')) {
15
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
16
+ if (match && !process.env[match[1]]) process.env[match[1]] = match[2].replace(/^"|"$/g, '')
17
+ }
18
+
19
+ const args = process.argv.slice(2)
20
+ const at = args.indexOf('--batch')
21
+ const BATCH = at === -1 ? 40 : Number(args[at + 1])
22
+
23
+ const db = createClient({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN })
24
+ const apiKey = process.env.RESEND_API_KEY
25
+
26
+ async function retry(work) {
27
+ for (let attempt = 1; ; attempt += 1) {
28
+ try {
29
+ return await work()
30
+ } catch (err) {
31
+ if (attempt >= 5) throw err
32
+ await new Promise(resolve => setTimeout(resolve, 3000 * attempt))
33
+ }
34
+ }
35
+ }
36
+
37
+ const PENDING = `FROM mail_inbox
38
+ WHERE id NOT LIKE 'mbox-%'
39
+ AND attachments LIKE '%"key"%'
40
+ AND attachments NOT LIKE '%contentId%'`
41
+
42
+ const remaining = Number(
43
+ Object.values((await retry(() => db.execute(`SELECT COUNT(*) ${PENDING}`))).rows[0])[0],
44
+ )
45
+ console.log(` ${remaining.toLocaleString()} message(s) to annotate\n`)
46
+
47
+ let done = 0
48
+ let embedded = 0
49
+ const started = Date.now()
50
+
51
+ for (let round = 1; round <= 5000; round += 1) {
52
+ const rows = (await retry(() => db.execute(`SELECT id, attachments ${PENDING} LIMIT ${BATCH}`))).rows
53
+ if (rows.length === 0) break
54
+
55
+ const updates = []
56
+ await Promise.all(
57
+ rows.map(async row => {
58
+ const id = String(row.id)
59
+ let stored = []
60
+ try {
61
+ stored = JSON.parse(String(row.attachments))
62
+ } catch {
63
+ return
64
+ }
65
+
66
+ const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
67
+ headers: { authorization: `Bearer ${apiKey}` },
68
+ }).catch(() => null)
69
+
70
+ // Mark it either way: a message the provider no longer holds must not be
71
+ // re-selected forever, so it is annotated with what we already know.
72
+ const listed = response?.ok ? ((await response.json()).data ?? []) : []
73
+ const merged = stored.map((file, index) => {
74
+ const match = listed[index] ?? listed.find(entry => String(entry.filename ?? '') === String(file.filename ?? ''))
75
+ const raw = match?.content_id ? String(match.content_id).replace(/^<|>$/g, '') : ''
76
+ if (raw && String(file.contentType ?? '').toLowerCase().startsWith('image/')) embedded += 1
77
+ return { ...file, contentId: raw || null }
78
+ })
79
+
80
+ updates.push({ id, json: JSON.stringify(merged) })
81
+ }),
82
+ )
83
+
84
+ for (const update of updates) {
85
+ await retry(() =>
86
+ db.execute({
87
+ sql: 'UPDATE mail_inbox SET attachments = ?, attach_meta = ? WHERE id = ?',
88
+ args: [update.json, update.json, update.id],
89
+ }),
90
+ )
91
+ }
92
+ done += updates.length
93
+ if (updates.length === 0) break
94
+
95
+ const rate = done / Math.max((Date.now() - started) / 1000, 1)
96
+ process.stdout.write(
97
+ `\r ${done}/${remaining} messages · ${embedded} embedded images found · ${rate.toFixed(1)}/s `,
98
+ )
99
+ }
100
+
101
+ console.log(`\n\n annotated ${done} message(s); ${embedded} embedded image(s) will now be hidden`)
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Fills the columns the message list reads, so it stops falling back to the message
3
+ * body. The fallback reads body_text, which sits behind the html blob in the record,
4
+ * and that is the difference between a list query of ~200ms and one of ~2s.
5
+ *
6
+ * Newest first: those are the rows every mailbox opens on, so the first few thousand
7
+ * buy nearly all of the benefit long before the archive is done.
8
+ *
9
+ * Resumable — a row is picked up only while snippet is NULL.
10
+ *
11
+ * node scripts/backfill-list-columns.mjs [--batch 500]
12
+ */
13
+
14
+ import { createClient } from '@libsql/client'
15
+ import { readFileSync } from 'node:fs'
16
+
17
+ for (const line of readFileSync(new URL('../.env.local', import.meta.url), 'utf8').split('\n')) {
18
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
19
+ if (match && !process.env[match[1]]) process.env[match[1]] = match[2].replace(/^"|"$/g, '')
20
+ }
21
+
22
+ const args = process.argv.slice(2)
23
+ const at = args.indexOf('--batch')
24
+ const BATCH = at === -1 ? 500 : Number(args[at + 1])
25
+
26
+ const db = createClient({
27
+ url: process.env.TURSO_DATABASE_URL,
28
+ authToken: process.env.TURSO_AUTH_TOKEN,
29
+ })
30
+
31
+ const UPDATE = `UPDATE mail_inbox SET
32
+ snippet = substr(COALESCE(body_text, ''), 1, 320),
33
+ thread_meta = json_object(
34
+ 'message-id', COALESCE(json_extract(headers, '$."message-id"'), ''),
35
+ 'in-reply-to', COALESCE(json_extract(headers, '$."in-reply-to"'), ''),
36
+ 'references', COALESCE(json_extract(headers, '$."references"'), '')),
37
+ attach_meta = COALESCE(
38
+ (SELECT json_group_array(json_object('filename', json_extract(value, '$.filename'),
39
+ 'contentType', json_extract(value, '$.contentType'),
40
+ 'size', json_extract(value, '$.size')))
41
+ FROM json_each(CASE WHEN json_valid(attachments) THEN attachments ELSE '[]' END)),
42
+ '[]')
43
+ WHERE id IN (SELECT id FROM mail_inbox WHERE snippet IS NULL ORDER BY received_at DESC LIMIT ${BATCH})`
44
+
45
+ /** Turso drops a connection now and then; a dropped batch is safe to repeat. */
46
+ async function run(sql) {
47
+ for (let attempt = 1; ; attempt += 1) {
48
+ try {
49
+ return await db.execute(sql)
50
+ } catch (err) {
51
+ if (attempt >= 5) throw err
52
+ await new Promise(resolve => setTimeout(resolve, 3000 * attempt))
53
+ }
54
+ }
55
+ }
56
+
57
+ const outstanding = Number(Object.values((await run('SELECT COUNT(*) FROM mail_inbox WHERE snippet IS NULL')).rows[0])[0])
58
+ console.log(` ${outstanding.toLocaleString()} rows to fill, ${BATCH} at a time\n`)
59
+
60
+ const started = Date.now()
61
+ let done = 0
62
+
63
+ while (done < outstanding) {
64
+ const result = await run(UPDATE)
65
+ if (!result.rowsAffected) break
66
+ done += result.rowsAffected
67
+
68
+ const elapsed = (Date.now() - started) / 1000
69
+ const rate = done / Math.max(elapsed, 1)
70
+ const eta = rate > 0 ? Math.round((outstanding - done) / rate) : 0
71
+ process.stdout.write(
72
+ `\r ${done.toLocaleString()}/${outstanding.toLocaleString()} · ${rate.toFixed(0)}/s · ` +
73
+ `~${Math.floor(eta / 60)}m ${eta % 60}s left `,
74
+ )
75
+ }
76
+
77
+ const total = Math.round((Date.now() - started) / 1000)
78
+ const left = Number(Object.values((await run('SELECT COUNT(*) FROM mail_inbox WHERE snippet IS NULL')).rows[0])[0])
79
+ console.log(`\n\n filled ${done.toLocaleString()} rows in ${Math.floor(total / 60)}m ${total % 60}s`)
80
+ console.log(` still NULL: ${left.toLocaleString()}`)