@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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Moves message bodies out of the database and into the bucket, leaving the key
3
+ * behind in the message headers. The reader already falls back to the bucket when a
4
+ * row has no html of its own, so nothing needs to change for this to take effect.
5
+ *
6
+ * Bytes are written first and the column is cleared only once the bucket has them,
7
+ * so an interrupted run loses nothing. Resumable — a row is picked up only while it
8
+ * still holds html.
9
+ *
10
+ * node --experimental-strip-types scripts/offload-bodies.mjs [--batch 40]
11
+ */
12
+
13
+ import { createClient } from '@libsql/client'
14
+ import { readFileSync } from 'node:fs'
15
+ import { presign } from '../lib/r2.ts'
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 ? 40 : 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
+ /** Ids are ours, but these go into string-interpolated SQL: anything unexpected is left alone. */
32
+ const safeId = value => /^[A-Za-z0-9._:+@-]{1,200}$/.test(value)
33
+
34
+ const readable = bytes => {
35
+ const units = ['B', 'KB', 'MB', 'GB']
36
+ let value = bytes
37
+ let unit = 0
38
+ while (value >= 1024 && unit < units.length - 1) {
39
+ value /= 1024
40
+ unit += 1
41
+ }
42
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`
43
+ }
44
+
45
+ async function retry(work) {
46
+ for (let attempt = 1; ; attempt += 1) {
47
+ try {
48
+ return await work()
49
+ } catch (err) {
50
+ if (attempt >= 5) throw err
51
+ await new Promise(resolve => setTimeout(resolve, 3000 * attempt))
52
+ }
53
+ }
54
+ }
55
+
56
+ const remaining = Number(
57
+ Object.values(
58
+ (await retry(() => db.execute("SELECT COUNT(*) FROM mail_inbox WHERE html IS NOT NULL AND html != ''"))).rows[0],
59
+ )[0],
60
+ )
61
+ console.log(`${new Date().toISOString()} ${remaining.toLocaleString()} bodies to move, ${BATCH} at a time`)
62
+
63
+ const started = Date.now()
64
+ let moved = 0
65
+ let deduped = 0
66
+ let failed = 0
67
+ let bytes = 0
68
+
69
+ for (let round = 1; round <= 10000; round += 1) {
70
+ const rows = (
71
+ await retry(() =>
72
+ db.execute(`SELECT id, html, headers FROM mail_inbox WHERE html IS NOT NULL AND html != '' LIMIT ${BATCH}`),
73
+ )
74
+ ).rows
75
+
76
+ if (rows.length === 0) break
77
+
78
+ const statements = []
79
+ await Promise.all(
80
+ rows.map(async row => {
81
+ const id = String(row.id)
82
+ const html = String(row.html ?? '')
83
+ if (!safeId(id)) return
84
+
85
+ let headers = {}
86
+ try {
87
+ headers = JSON.parse(String(row.headers ?? '{}')) ?? {}
88
+ } catch {
89
+ headers = {}
90
+ }
91
+
92
+ // A shared copy points at the original, which already holds the body.
93
+ if (typeof headers['shared-copy-of'] === 'string' && headers['shared-copy-of']) {
94
+ statements.push(`UPDATE mail_inbox SET html = NULL WHERE id = '${id}'`)
95
+ deduped += 1
96
+ bytes += html.length
97
+ return
98
+ }
99
+
100
+ const key = `bodies/${id}.html`
101
+ try {
102
+ const response = await fetch(presign(key, 'PUT', 300), {
103
+ method: 'PUT',
104
+ headers: { 'content-type': 'text/html; charset=utf-8' },
105
+ body: html,
106
+ })
107
+ if (!response.ok) {
108
+ failed += 1
109
+ return
110
+ }
111
+ } catch {
112
+ failed += 1
113
+ return
114
+ }
115
+
116
+ statements.push(
117
+ `UPDATE mail_inbox SET html = NULL, headers = json_set(COALESCE(headers, '{}'), '$."html-key"', '${key}') WHERE id = '${id}'`,
118
+ )
119
+ moved += 1
120
+ bytes += html.length
121
+ }),
122
+ )
123
+
124
+ if (statements.length) await retry(() => db.batch(statements, 'write'))
125
+
126
+ // Every row failed to upload: the bucket is unreachable, and looping would spin forever.
127
+ if (statements.length === 0) {
128
+ console.log(`\n${new Date().toISOString()} stopping: nothing in this batch could be written to the bucket`)
129
+ break
130
+ }
131
+
132
+ if (round % 10 === 0) {
133
+ const elapsed = (Date.now() - started) / 1000
134
+ const rate = (moved + deduped) / Math.max(elapsed, 1)
135
+ const left = Math.max(remaining - moved - deduped, 0)
136
+ const eta = rate > 0 ? Math.round(left / rate) : 0
137
+ console.log(
138
+ `${new Date().toISOString()} ${(moved + deduped).toLocaleString()}/${remaining.toLocaleString()} · ` +
139
+ `${readable(bytes)} freed · ${rate.toFixed(1)}/s · ~${Math.floor(eta / 60)}m left`,
140
+ )
141
+ }
142
+ }
143
+
144
+ const total = Math.round((Date.now() - started) / 1000)
145
+ const left = Number(
146
+ Object.values(
147
+ (await retry(() => db.execute("SELECT COUNT(*) FROM mail_inbox WHERE html IS NOT NULL AND html != ''"))).rows[0],
148
+ )[0],
149
+ )
150
+ console.log(`\n${new Date().toISOString()} finished in ${Math.floor(total / 60)}m ${total % 60}s`)
151
+ console.log(` moved ${moved.toLocaleString()}, deduped ${deduped.toLocaleString()}, failed ${failed.toLocaleString()}`)
152
+ console.log(` ${readable(bytes)} taken out of the database, ${left.toLocaleString()} bodies still inline`)
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Real files for the seeded mailbox: a message that says it carries a photo should carry
3
+ * a photo you can open. Everything here is generated, so the repository ships no media.
4
+ *
5
+ * ffmpeg draws the picture and the video when it is installed; the PDF, the spreadsheet
6
+ * and the document are written by hand, and the rest is skipped rather than faked.
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ function has(command) {
14
+ try {
15
+ execFileSync('which', [command], { stdio: 'ignore' })
16
+ return true
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ /** A one-page PDF, with the byte offsets its cross-reference table has to name. */
23
+ function pdf(title, line) {
24
+ const objects = [
25
+ '<</Type/Catalog/Pages 2 0 R>>',
26
+ '<</Type/Pages/Kids[3 0 R]/Count 1>>',
27
+ '<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 5 0 R>>>>/Contents 4 0 R>>',
28
+ null,
29
+ '<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>',
30
+ ]
31
+ const stream = `BT /F1 20 Tf 64 760 Td (${title}) Tj ET\nBT /F1 12 Tf 64 730 Td (${line}) Tj ET`
32
+ objects[3] = `<</Length ${stream.length}>>\nstream\n${stream}\nendstream`
33
+
34
+ let body = '%PDF-1.4\n'
35
+ const offsets = []
36
+ objects.forEach((object, index) => {
37
+ offsets.push(body.length)
38
+ body += `${index + 1} 0 obj\n${object}\nendobj\n`
39
+ })
40
+ const startxref = body.length
41
+ body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`
42
+ for (const offset of offsets) body += `${String(offset).padStart(10, '0')} 00000 n \n`
43
+ body += `trailer\n<</Size ${objects.length + 1}/Root 1 0 R>>\nstartxref\n${startxref}\n%%EOF\n`
44
+ return Buffer.from(body, 'latin1')
45
+ }
46
+
47
+ /** A .docx is a zip of three small XML parts; the zip tool does the rest. */
48
+ function docx(text, directory) {
49
+ if (!has('zip')) return null
50
+ const root = join(directory, 'docx')
51
+ mkdirSync(join(root, '_rels'), { recursive: true })
52
+ mkdirSync(join(root, 'word'), { recursive: true })
53
+ writeFileSync(
54
+ join(root, '[Content_Types].xml'),
55
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
56
+ )
57
+ writeFileSync(
58
+ join(root, '_rels/.rels'),
59
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
60
+ )
61
+ writeFileSync(
62
+ join(root, 'word/document.xml'),
63
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:body></w:document>`,
64
+ )
65
+ const out = join(directory, 'endorsement.docx')
66
+ execFileSync('zip', ['-q', '-r', '-X', out, '[Content_Types].xml', '_rels', 'word'], { cwd: root })
67
+ return readFileSync(out)
68
+ }
69
+
70
+ function ffmpeg(directory, name, args) {
71
+ if (!has('ffmpeg')) return null
72
+ const out = join(directory, name)
73
+ try {
74
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...args, out], { stdio: 'ignore' })
75
+ return readFileSync(out)
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ export function buildSamples() {
82
+ const directory = mkdtempSync(join(tmpdir(), 'mailbox-samples-'))
83
+ try {
84
+ const files = [
85
+ { filename: 'schedule.pdf', contentType: 'application/pdf', bytes: pdf('Premium schedule', 'Cover from 1 January, renewable annually.') },
86
+ { filename: 'certificate.pdf', contentType: 'application/pdf', bytes: pdf('Certificate of insurance', 'Issued to the named insured for the period shown.') },
87
+ { filename: 'claim-form.pdf', contentType: 'application/pdf', bytes: pdf('Claim form', 'Complete every section and return it signed.') },
88
+ {
89
+ filename: 'vehicle-list.csv',
90
+ contentType: 'text/csv',
91
+ bytes: Buffer.from(
92
+ 'registration,make,model,year,value\nLAG-114-KJA,Toyota,Hilux,2021,18500000\nABJ-882-XA,Ford,Ranger,2019,14200000\nLAG-props-77,Mercedes,Sprinter,2022,31000000\n',
93
+ 'utf8',
94
+ ),
95
+ },
96
+ {
97
+ filename: 'site-photo.jpg',
98
+ contentType: 'image/jpeg',
99
+ bytes: ffmpeg(directory, 'site-photo.jpg', ['-f', 'lavfi', '-i', 'testsrc=size=1280x720:duration=1', '-frames:v', '1']),
100
+ },
101
+ {
102
+ filename: 'warehouse.png',
103
+ contentType: 'image/png',
104
+ bytes: ffmpeg(directory, 'warehouse.png', ['-f', 'lavfi', '-i', 'smptebars=size=900x600:duration=1', '-frames:v', '1']),
105
+ },
106
+ {
107
+ filename: 'survey-clip.mp4',
108
+ contentType: 'video/mp4',
109
+ bytes: ffmpeg(directory, 'survey-clip.mp4', [
110
+ '-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=15', '-t', '3', '-pix_fmt', 'yuv420p', '-c:v', 'libx264',
111
+ ]),
112
+ },
113
+ { filename: 'endorsement.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', bytes: docx('Endorsement: the schedule is amended as set out above.', directory) },
114
+ ]
115
+ return files.filter(file => file.bytes && file.bytes.length)
116
+ } finally {
117
+ rmSync(directory, { recursive: true, force: true })
118
+ }
119
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Fills the dev mailbox with enough traffic to exercise the list, search, paging
3
+ * and the large-file path. Idempotent: every row it writes is prefixed `dev-`,
4
+ * so re-running replaces rather than accumulates.
5
+ *
6
+ * node scripts/seed-dev.mjs
7
+ */
8
+
9
+ import { createClient } from '@libsql/client'
10
+ import { randomBytes, scrypt } from 'node:crypto'
11
+ import { promisify } from 'node:util'
12
+ import { readFileSync } from 'node:fs'
13
+
14
+ const scryptAsync = promisify(scrypt)
15
+
16
+ // Read .env.local the same way Next does, so this needs no extra wiring.
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 db = createClient({
23
+ url: process.env.TURSO_DATABASE_URL,
24
+ authToken: process.env.TURSO_AUTH_TOKEN,
25
+ })
26
+
27
+ const DEV_ADDRESS = 'test@example.com'
28
+ /** Written into the body of every message whose link is locked, the way a sender would. */
29
+ const SHARE_PASSWORD = 'open-sesame'
30
+ const TOTAL = 500
31
+ const WITH_ATTACHMENTS = 10
32
+ const WITH_HEAVY = 10
33
+
34
+ const SUBJECTS = [
35
+ 'Marine cargo cover for a Shenzhen shipment', 'Motor fleet renewal, twelve vehicles',
36
+ 'Group life for forty staff', 'Builders liability on the Ikoyi site',
37
+ 'Goods in transit, weekly runs to Aba', 'Professional indemnity renewal',
38
+ 'Fire and perils survey report', 'Fidelity guarantee bond wording',
39
+ 'Travel policy for the directors', 'Container insurance question',
40
+ 'Claim notification, warehouse flood', 'Certificate needed for Form M',
41
+ 'Premium schedule for review', 'Query on the excess clause',
42
+ 'Renewal terms, please confirm', 'Adding a driver to the fleet policy',
43
+ 'Endorsement request', 'Cover note for tomorrow',
44
+ 'Broker appointment letter', 'Annual review meeting',
45
+ ]
46
+
47
+ const COMPANIES = [
48
+ 'Lagos Freight', 'Zenith Logistics', 'Tabernacle Group', 'BuildRight', 'Harbour Traders',
49
+ 'Ikeja Motors', 'Delta Shipping', 'Crown Estates', 'Nova Foods', 'Apex Manufacturing',
50
+ 'Sable Energy', 'Riverside Farms', 'Onyx Retail', 'Pioneer Textiles', 'Summit Health',
51
+ ]
52
+
53
+ const BODIES = [
54
+ 'Please find the details below. Let me know if you need anything further before we proceed.',
55
+ 'Following up on our call this morning. The figures we discussed are attached for your records.',
56
+ 'We would like to confirm the terms before the renewal date. Could you send the schedule?',
57
+ 'There has been a change to the risk since the last renewal. Details follow.',
58
+ 'Our bank is asking for the certificate before they will release the shipment.',
59
+ 'Could you clarify what the excess would be on a claim of this size?',
60
+ 'We are adding two vehicles to the fleet from the first of next month.',
61
+ 'The survey has been completed. The report raises two points we should discuss.',
62
+ ]
63
+
64
+ const pick = (list, index) => list[index % list.length]
65
+
66
+ /** A deterministic sequence, so a re-run produces the same mailbox. */
67
+ function seededRandom(seed) {
68
+ let value = seed
69
+ return () => {
70
+ value = (value * 1664525 + 1013904223) % 4294967296
71
+ return value / 4294967296
72
+ }
73
+ }
74
+
75
+ /** The shape lib/password.ts verifies. A bare digest is not it, and never matched. */
76
+ async function hashPassword(plain) {
77
+ const salt = randomBytes(16)
78
+ const key = await scryptAsync(plain, salt, 64, { N: 16384 })
79
+ return `scrypt$16384$${salt.toString('hex')}$${key.toString('hex')}`
80
+ }
81
+
82
+ async function ensureDevAccount() {
83
+ const hash = await hashPassword(DEV_ADDRESS)
84
+ await db.execute({
85
+ sql: `INSERT INTO mail_accounts (email, role, name, address, status, created_at, password_hash, password_is_default)
86
+ VALUES (?, 'member', 'Dev Test', ?, 'active', ?, ?, 1)
87
+ ON CONFLICT (email) DO UPDATE SET status = 'active', address = excluded.address`,
88
+ args: [DEV_ADDRESS, DEV_ADDRESS, new Date().toISOString(), hash],
89
+ })
90
+ return hash
91
+ }
92
+
93
+ async function main() {
94
+ await ensureDevAccount()
95
+ console.log(` account ready: ${DEV_ADDRESS}`)
96
+
97
+ await db.execute("DELETE FROM mail_inbox WHERE id LIKE 'dev-%'")
98
+ await db.execute("DELETE FROM mail_shares WHERE id LIKE 'devshare%'")
99
+
100
+ const random = seededRandom(20260904)
101
+ const now = Date.now()
102
+
103
+ // The heavy ones get a share record each, so the download page has something
104
+ // real to render — the object itself is not uploaded, only the record.
105
+ const heavyFiles = [
106
+ ['site-survey-photos.zip', 1_850_000_000, 'application/zip'],
107
+ ['warehouse-walkthrough.mp4', 1_240_000_000, 'video/mp4'],
108
+ ['fleet-inspection-4k.mov', 980_000_000, 'video/quicktime'],
109
+ ['claims-archive-2019-2025.zip', 740_000_000, 'application/zip'],
110
+ ['policy-scans-full.pdf', 512_000_000, 'application/pdf'],
111
+ ['drone-footage-ikoyi.mp4', 430_000_000, 'video/mp4'],
112
+ ['premium-model.xlsx', 96_000_000, 'application/vnd.ms-excel'],
113
+ ['risk-register.csv', 62_000_000, 'text/csv'],
114
+ ['broker-pack.pdf', 48_000_000, 'application/pdf'],
115
+ ['loss-runs.zip', 31_000_000, 'application/zip'],
116
+ ]
117
+
118
+ // Real files, uploaded once and then shared by every message that claims to carry one,
119
+ // so opening an attachment in the seeded mailbox opens something. Without a bucket
120
+ // configured the records are written on their own, as they always were.
121
+ const { buildSamples } = await import('./sample-files.mjs')
122
+ const bucketReady = Boolean(process.env.R2_BUCKET && process.env.R2_ACCESS_KEY_ID && process.env.R2_S3_ENDPOINT)
123
+ let smallFiles = []
124
+ if (bucketReady) {
125
+ const { putObject } = await import('../lib/r2.ts')
126
+ for (const sample of buildSamples()) {
127
+ const key = `dev/samples/${sample.filename}`
128
+ const stored = await putObject(key, sample.bytes, sample.contentType)
129
+ if (stored) smallFiles.push([sample.filename, sample.bytes.length, sample.contentType, key])
130
+ }
131
+ console.log(` ${smallFiles.length} sample files uploaded, and shared by the messages that carry one`)
132
+ }
133
+ if (!smallFiles.length) {
134
+ console.log(' no bucket configured: attachments are recorded but hold no bytes')
135
+ smallFiles = [
136
+ ['schedule.pdf', 240_000, 'application/pdf'],
137
+ ['certificate.pdf', 180_000, 'application/pdf'],
138
+ ['invoice.pdf', 96_000, 'application/pdf'],
139
+ ['endorsement.docx', 54_000, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
140
+ ['vehicle-list.csv', 128_000, 'text/csv'],
141
+ ['photo-front.jpg', 320_000, 'image/jpeg'],
142
+ ['photo-rear.jpg', 298_000, 'image/jpeg'],
143
+ ['claim-form.pdf', 145_000, 'application/pdf'],
144
+ ['valuation.pdf', 210_000, 'application/pdf'],
145
+ ['cover-note.pdf', 88_000, 'application/pdf'],
146
+ ]
147
+ }
148
+
149
+ const rows = []
150
+ const shares = []
151
+
152
+ for (let index = 0; index < TOTAL; index += 1) {
153
+ const id = `dev-${String(index).padStart(4, '0')}`
154
+ const company = pick(COMPANIES, index)
155
+ const slug = company.toLowerCase().replace(/[^a-z]+/g, '')
156
+ // Every message comes from a different yopmail address.
157
+ const from = `${slug}${index}@yopmail.com`
158
+ const subject = pick(SUBJECTS, index)
159
+ const body = pick(BODIES, index)
160
+ const receivedAt = new Date(now - index * (1000 * 60 * 37)).toISOString()
161
+
162
+ let attachments = []
163
+ // Every other heavy file is locked, so both the open and the protected path can be
164
+ // walked. A real sender tells the recipient the password, so these do too.
165
+ let passwordNote = ''
166
+ if (index < WITH_HEAVY) {
167
+ const [filename, size, contentType] = heavyFiles[index]
168
+ const shareId = `devshare${String(index).padStart(2, '0')}`
169
+ const locked = index % 4 === 0
170
+ shares.push([shareId, `shares/dev/${filename}`, filename, contentType, size, locked])
171
+ attachments = [{ filename, contentType, size, shareId }]
172
+ passwordNote = locked
173
+ ? `\n\nThe file is too large to attach, so it is behind a link. The password is "${SHARE_PASSWORD}".`
174
+ : '\n\nThe file is too large to attach, so it is behind a link. No password is needed.'
175
+ } else if (index < WITH_HEAVY + WITH_ATTACHMENTS) {
176
+ const [filename, size, contentType, key] = smallFiles[(index - WITH_HEAVY) % smallFiles.length]
177
+ attachments = [key ? { filename, contentType, size, key } : { filename, contentType, size }]
178
+ }
179
+
180
+ rows.push([
181
+ id, from, JSON.stringify([DEV_ADDRESS]), '[]', '[]', '[]',
182
+ `${subject} — ${company}`, '',
183
+ `${body}${passwordNote}\n\nRegards,\n${company}`,
184
+ '{}', receivedAt, random() > 0.55 ? 1 : 0,
185
+ JSON.stringify(attachments),
186
+ random() > 0.9 ? 1 : 0, 0, 0, '[]', DEV_ADDRESS,
187
+ ])
188
+ }
189
+
190
+ for (const [id, key, filename, contentType, size, locked] of shares) {
191
+ await db.execute({
192
+ sql: `INSERT OR REPLACE INTO mail_shares
193
+ (id, object_key, filename, content_type, size, password_hash, owner, created_at, expires_at, max_downloads)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
195
+ args: [
196
+ id, key, filename, contentType, size,
197
+ locked ? await hashPassword(SHARE_PASSWORD) : null,
198
+ DEV_ADDRESS, new Date().toISOString(),
199
+ new Date(now + 30 * 86400_000).toISOString(), null,
200
+ ],
201
+ })
202
+ }
203
+ const locked = shares.filter(share => share[5]).length
204
+ console.log(` ${shares.length} share records, ${locked} of them locked with "${SHARE_PASSWORD}", which their messages say`)
205
+
206
+ // Batched: 500 individual round trips to Turso would take minutes.
207
+ const CHUNK = 50
208
+ for (let start = 0; start < rows.length; start += CHUNK) {
209
+ await db.batch(
210
+ rows.slice(start, start + CHUNK).map(args => ({
211
+ sql: `INSERT OR REPLACE INTO mail_inbox
212
+ (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers,
213
+ received_at, read, attachments, starred, archived, trashed, labels, owner)
214
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
215
+ args,
216
+ })),
217
+ 'write',
218
+ )
219
+ process.stdout.write(`\r seeding ${Math.min(start + CHUNK, rows.length)}/${rows.length}`)
220
+ }
221
+
222
+ const total = await db.execute({
223
+ sql: 'SELECT COUNT(*) AS n FROM mail_inbox WHERE owner = ?',
224
+ args: [DEV_ADDRESS],
225
+ })
226
+ const withAttachments = await db.execute({
227
+ sql: `SELECT COUNT(*) AS n FROM mail_inbox WHERE owner = ? AND attachments != '[]'`,
228
+ args: [DEV_ADDRESS],
229
+ })
230
+ const indexed = await db.execute('SELECT COUNT(*) AS n FROM mail_inbox_fts')
231
+
232
+ console.log(`\n ${total.rows[0].n} messages for ${DEV_ADDRESS}`)
233
+ console.log(` ${withAttachments.rows[0].n} carry attachments (${WITH_HEAVY} heavy, ${WITH_ATTACHMENTS} ordinary)`)
234
+ console.log(` search index rows: ${indexed.rows[0].n}`)
235
+ }
236
+
237
+ main().catch(err => {
238
+ console.error(' seed failed:', err.message)
239
+ process.exit(1)
240
+ })
@@ -0,0 +1,18 @@
1
+ # Tools
2
+
3
+ > **Never deploy or `git push` unless asked for it in that request.** Pushing
4
+ > `master` auto-deploys both live mailboxes. See the deploying section in the
5
+ > root [README](../README.md).
6
+
7
+ | File | What it does |
8
+ |---|---|
9
+ | `import-mbox.py` | Import an mbox into a mailbox. Bodies and metadata first, attachments second. |
10
+ | `import-takeout.sh` | Wrapper: import one Google Takeout zip for one seat, streaming the mbox out of the zip. Set `IMPORT_LOG_DIR`. |
11
+ | `db-backup.py` | Consistent snapshot of the mailbox database. |
12
+ | `doh.py` | DNS-over-HTTPS lookups, for checking mail records without a local resolver. |
13
+
14
+ Three scripts that used to live here — `night-plan.sh`, `uplink-lane.sh` and
15
+ `takeout-queue.sh` — were a run-book for one tenant's migration: named seats,
16
+ specific archive filenames, and an external drive. They also depended on a
17
+ `mbox-scan.py` that was never in this repo. They stay in the mailbox they were
18
+ written for rather than shipping here as something to copy.