@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- 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()}`)
|