@novacraft-engineering/mailbox 0.1.0 → 0.2.1

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 (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +23 -4
  18. package/app/mail/RichEditor.tsx +167 -53
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +643 -76
  21. package/app/mail/page.tsx +1062 -247
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
@@ -2,7 +2,7 @@
2
2
  * Real files for the seeded mailbox: a message that says it carries a photo should carry
3
3
  * a photo you can open. Everything here is generated, so the repository ships no media.
4
4
  *
5
- * ffmpeg draws the picture and the video when it is installed; the PDF, the spreadsheet
5
+ * ffmpeg draws the pictures and the video when it is installed; the PDF, the spreadsheet
6
6
  * and the document are written by hand, and the rest is skipped rather than faked.
7
7
  */
8
8
  import { execFileSync } from 'node:child_process'
@@ -19,17 +19,26 @@ function has(command) {
19
19
  }
20
20
  }
21
21
 
22
- /** A one-page PDF, with the byte offsets its cross-reference table has to name. */
23
- function pdf(title, line) {
22
+ /** A PDF of `pages` pages, with the byte offsets its cross-reference table has to name. */
23
+ function pdf(title, line, pages = 1) {
24
+ const stream = `BT /F1 20 Tf 64 760 Td (${title}) Tj ET\nBT /F1 12 Tf 64 730 Td (${line}) Tj ET`
25
+ const contentObject = `<</Length ${stream.length}>>\nstream\n${stream}\nendstream`
26
+ const fontNumber = 3 + pages * 2
27
+
28
+ const kids = []
29
+ for (let page = 0; page < pages; page += 1) kids.push(`${3 + page * 2} 0 R`)
30
+
24
31
  const objects = [
25
32
  '<</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>>',
33
+ `<</Type/Pages/Kids[${kids.join(' ')}]/Count ${pages}>>`,
30
34
  ]
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`
35
+ for (let page = 0; page < pages; page += 1) {
36
+ objects.push(
37
+ `<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</Font<</F1 ${fontNumber} 0 R>>>>/Contents ${4 + page * 2} 0 R>>`,
38
+ )
39
+ objects.push(contentObject)
40
+ }
41
+ objects.push('<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>')
33
42
 
34
43
  let body = '%PDF-1.4\n'
35
44
  const offsets = []
@@ -44,29 +53,93 @@ function pdf(title, line) {
44
53
  return Buffer.from(body, 'latin1')
45
54
  }
46
55
 
47
- /** A .docx is a zip of three small XML parts; the zip tool does the rest. */
48
- function docx(text, directory) {
56
+ /** Writes `entries` into a directory and zips it, which is how every Office format is built. */
57
+ function zipOf(directory, label, entries) {
49
58
  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 })
59
+ const root = join(directory, label)
60
+ for (const [path, content] of entries) {
61
+ const full = join(root, path)
62
+ mkdirSync(join(full, '..'), { recursive: true })
63
+ writeFileSync(full, content)
64
+ }
65
+ const out = join(directory, `${label}.zip`)
66
+ rmSync(out, { force: true })
67
+ const roots = [...new Set(entries.map(([path]) => path.split('/')[0]))]
68
+ execFileSync('zip', ['-q', '-r', '-X', out, ...roots], { cwd: root })
67
69
  return readFileSync(out)
68
70
  }
69
71
 
72
+ function docx(text, directory) {
73
+ return zipOf(directory, 'docx', [
74
+ [
75
+ '[Content_Types].xml',
76
+ '<?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>',
77
+ ],
78
+ [
79
+ '_rels/.rels',
80
+ '<?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>',
81
+ ],
82
+ [
83
+ 'word/document.xml',
84
+ `<?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>`,
85
+ ],
86
+ ])
87
+ }
88
+
89
+ /** A spreadsheet of `rowCount` rows, built as the minimal set of parts Excel will open. */
90
+ function xlsx(directory, headers, rowFor, rowCount) {
91
+ if (headers.length > 26) throw new Error('xlsx(): single-letter columns only, add AA-style names first')
92
+ const cellName = column => String.fromCharCode(65 + column)
93
+ let sheet = ''
94
+ sheet += `<row r="1">${headers.map((header, column) => `<c r="${cellName(column)}1" t="inlineStr"><is><t>${header}</t></is></c>`).join('')}</row>`
95
+ for (let index = 0; index < rowCount; index += 1) {
96
+ const values = rowFor(index)
97
+ const cells = values
98
+ .map((value, column) =>
99
+ typeof value === 'number'
100
+ ? `<c r="${cellName(column)}${index + 2}"><v>${value}</v></c>`
101
+ : `<c r="${cellName(column)}${index + 2}" t="inlineStr"><is><t>${value}</t></is></c>`,
102
+ )
103
+ .join('')
104
+ sheet += `<row r="${index + 2}">${cells}</row>`
105
+ }
106
+
107
+ return zipOf(directory, 'xlsx', [
108
+ [
109
+ '[Content_Types].xml',
110
+ '<?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"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
111
+ ],
112
+ [
113
+ '_rels/.rels',
114
+ '<?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="xl/workbook.xml"/></Relationships>',
115
+ ],
116
+ [
117
+ 'xl/workbook.xml',
118
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Model" sheetId="1" r:id="rId1"/></sheets></workbook>',
119
+ ],
120
+ [
121
+ 'xl/_rels/workbook.xml.rels',
122
+ '<?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/worksheet" Target="worksheets/sheet1.xml"/></Relationships>',
123
+ ],
124
+ [
125
+ 'xl/worksheets/sheet1.xml',
126
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${sheet}</sheetData></worksheet>`,
127
+ ],
128
+ ])
129
+ }
130
+
131
+ /**
132
+ * A synthetic pattern compresses almost to nothing, which left a clip named "4k" weighing
133
+ * 30 kB however the bitrate was pinned. Per-frame grain is what gives the encoder
134
+ * something incompressible to carry, so the file ends up the size its name implies.
135
+ */
136
+ function clip(directory, name, size, seconds) {
137
+ return ffmpeg(directory, name, [
138
+ '-f', 'lavfi', '-i', `testsrc=size=${size}:rate=25`, '-t', String(seconds),
139
+ '-vf', 'noise=alls=32:allf=t+u', '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-preset', 'veryfast',
140
+ ])
141
+ }
142
+
70
143
  function ffmpeg(directory, name, args) {
71
144
  if (!has('ffmpeg')) return null
72
145
  const out = join(directory, name)
@@ -78,6 +151,28 @@ function ffmpeg(directory, name, args) {
78
151
  }
79
152
  }
80
153
 
154
+ /** A zip whose members are the files given as [name, bytes]. */
155
+ function archive(directory, label, members) {
156
+ return zipOf(directory, label, members.map(([name, bytes]) => [`${label}/${name}`, bytes]))
157
+ }
158
+
159
+ const REGISTRATIONS = ['LAG', 'ABJ', 'KAN', 'PHC', 'IBD']
160
+
161
+ function csvRows(rowCount) {
162
+ let text = 'reference,location,peril,sum_insured,rate,premium,inception,expiry\n'
163
+ for (let index = 0; index < rowCount; index += 1) {
164
+ const sumInsured = 2_500_000 + (index % 900) * 125_000
165
+ const rate = 0.45 + (index % 37) / 100
166
+ text +=
167
+ `MP-${String(index).padStart(7, '0')},` +
168
+ `${REGISTRATIONS[index % REGISTRATIONS.length]} depot ${index % 240},` +
169
+ `${['Fire', 'Flood', 'Burglary', 'Machinery', 'Transit'][index % 5]},` +
170
+ `${sumInsured},${rate.toFixed(2)},${Math.round((sumInsured * rate) / 100)},` +
171
+ `2025-0${(index % 9) + 1}-01,2026-0${(index % 9) + 1}-01\n`
172
+ }
173
+ return Buffer.from(text, 'utf8')
174
+ }
175
+
81
176
  export function buildSamples() {
82
177
  const directory = mkdtempSync(join(tmpdir(), 'mailbox-samples-'))
83
178
  try {
@@ -117,3 +212,88 @@ export function buildSamples() {
117
212
  rmSync(directory, { recursive: true, force: true })
118
213
  }
119
214
  }
215
+
216
+ /**
217
+ * The files the seeded mailbox hands over as links rather than attachments. They are
218
+ * bigger than the ordinary samples and genuinely of their stated type, so the share
219
+ * page, the password gate and the download all resolve to something openable.
220
+ */
221
+ export function buildHeavySamples() {
222
+ const directory = mkdtempSync(join(tmpdir(), 'mailbox-heavy-'))
223
+ try {
224
+ const photo = ffmpeg(directory, 'frame.jpg', ['-f', 'lavfi', '-i', 'testsrc=size=1920x1080:duration=1', '-frames:v', '1'])
225
+ const scan = pdf('Policy scan', 'Scanned page of the placing file.', 40)
226
+
227
+ const files = [
228
+ {
229
+ filename: 'site-survey-photos.zip',
230
+ contentType: 'application/zip',
231
+ bytes: photo
232
+ ? archive(
233
+ directory,
234
+ 'site-survey-photos',
235
+ Array.from({ length: 24 }, (_, index) => [`survey-${String(index + 1).padStart(3, '0')}.jpg`, photo]),
236
+ )
237
+ : null,
238
+ },
239
+ {
240
+ filename: 'warehouse-walkthrough.mp4',
241
+ contentType: 'video/mp4',
242
+ bytes: clip(directory, 'warehouse-walkthrough.mp4', '1280x720', 5),
243
+ },
244
+ {
245
+ filename: 'fleet-inspection-4k.mov',
246
+ contentType: 'video/quicktime',
247
+ bytes: clip(directory, 'fleet-inspection-4k.mov', '1920x1080', 3),
248
+ },
249
+ {
250
+ filename: 'claims-archive-2019-2025.zip',
251
+ contentType: 'application/zip',
252
+ bytes: archive(
253
+ directory,
254
+ 'claims-archive',
255
+ Array.from({ length: 7 }, (_, index) => [
256
+ `claims-${2019 + index}.csv`,
257
+ csvRows(9_000),
258
+ ]),
259
+ ),
260
+ },
261
+ {
262
+ filename: 'policy-scans-full.pdf',
263
+ contentType: 'application/pdf',
264
+ bytes: pdf('Policy scans', 'Complete placing file, scanned front to back.', 900),
265
+ },
266
+ {
267
+ filename: 'drone-footage-ikoyi.mp4',
268
+ contentType: 'video/mp4',
269
+ bytes: clip(directory, 'drone-footage-ikoyi.mp4', '1280x720', 4),
270
+ },
271
+ {
272
+ filename: 'premium-model.xlsx',
273
+ contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
274
+ bytes: xlsx(
275
+ directory,
276
+ ['Reference', 'Class', 'Sum insured', 'Rate', 'Premium'],
277
+ index => [
278
+ `MP-${String(index).padStart(6, '0')}`,
279
+ ['Fire', 'Motor', 'Marine', 'Engineering', 'Liability'][index % 5],
280
+ 2_500_000 + (index % 900) * 125_000,
281
+ Number((0.45 + (index % 37) / 100).toFixed(2)),
282
+ Math.round((2_500_000 + (index % 900) * 125_000) * (0.45 + (index % 37) / 100)) / 100,
283
+ ],
284
+ 12_000,
285
+ ),
286
+ },
287
+ { filename: 'risk-register.csv', contentType: 'text/csv', bytes: csvRows(30_000) },
288
+ { filename: 'broker-pack.pdf', contentType: 'application/pdf', bytes: pdf('Broker pack', 'Placing information, schedules and loss record.', 300) },
289
+ {
290
+ filename: 'loss-runs.zip',
291
+ contentType: 'application/zip',
292
+ bytes: scan ? archive(directory, 'loss-runs', [['loss-run-2024.pdf', scan], ['loss-run-2025.pdf', scan], ['summary.csv', csvRows(4_000)]]) : null,
293
+ },
294
+ ]
295
+ return files.filter(file => file.bytes && file.bytes.length)
296
+ } finally {
297
+ rmSync(directory, { recursive: true, force: true })
298
+ }
299
+ }
@@ -29,7 +29,6 @@ const DEV_ADDRESS = 'test@example.com'
29
29
  const SHARE_PASSWORD = 'open-sesame'
30
30
  const TOTAL = 500
31
31
  const WITH_ATTACHMENTS = 10
32
- const WITH_HEAVY = 10
33
32
 
34
33
  const SUBJECTS = [
35
34
  'Marine cargo cover for a Shenzhen shipment', 'Motor fleet renewal, twelve vehicles',
@@ -100,27 +99,13 @@ async function main() {
100
99
  const random = seededRandom(20260904)
101
100
  const now = Date.now()
102
101
 
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
102
  // Real files, uploaded once and then shared by every message that claims to carry one,
119
103
  // so opening an attachment in the seeded mailbox opens something. Without a bucket
120
104
  // configured the records are written on their own, as they always were.
121
- const { buildSamples } = await import('./sample-files.mjs')
105
+ const { buildSamples, buildHeavySamples } = await import('./sample-files.mjs')
122
106
  const bucketReady = Boolean(process.env.R2_BUCKET && process.env.R2_ACCESS_KEY_ID && process.env.R2_S3_ENDPOINT)
123
107
  let smallFiles = []
108
+ let heavyFiles = []
124
109
  if (bucketReady) {
125
110
  const { putObject } = await import('../lib/r2.ts')
126
111
  for (const sample of buildSamples()) {
@@ -129,6 +114,31 @@ async function main() {
129
114
  if (stored) smallFiles.push([sample.filename, sample.bytes.length, sample.contentType, key])
130
115
  }
131
116
  console.log(` ${smallFiles.length} sample files uploaded, and shared by the messages that carry one`)
117
+
118
+ for (const sample of buildHeavySamples()) {
119
+ const key = `shares/dev/${sample.filename}`
120
+ const stored = await putObject(key, sample.bytes, sample.contentType)
121
+ if (stored) heavyFiles.push([sample.filename, sample.bytes.length, sample.contentType, key])
122
+ }
123
+ const heavyBytes = heavyFiles.reduce((total, file) => total + file[1], 0)
124
+ console.log(` ${heavyFiles.length} linked files uploaded, ${(heavyBytes / 1e6).toFixed(0)} MB in all`)
125
+ }
126
+ // Without a bucket the share records still exist so the link page renders; the download
127
+ // is what cannot work, and the route already says so rather than failing obscurely.
128
+ if (!heavyFiles.length) {
129
+ console.log(' no bucket configured: linked files are recorded but hold no bytes')
130
+ heavyFiles = [
131
+ ['site-survey-photos.zip', 1_850_000_000, 'application/zip', 'shares/dev/site-survey-photos.zip'],
132
+ ['warehouse-walkthrough.mp4', 1_240_000_000, 'video/mp4', 'shares/dev/warehouse-walkthrough.mp4'],
133
+ ['fleet-inspection-4k.mov', 980_000_000, 'video/quicktime', 'shares/dev/fleet-inspection-4k.mov'],
134
+ ['claims-archive-2019-2025.zip', 740_000_000, 'application/zip', 'shares/dev/claims-archive-2019-2025.zip'],
135
+ ['policy-scans-full.pdf', 512_000_000, 'application/pdf', 'shares/dev/policy-scans-full.pdf'],
136
+ ['drone-footage-ikoyi.mp4', 430_000_000, 'video/mp4', 'shares/dev/drone-footage-ikoyi.mp4'],
137
+ ['premium-model.xlsx', 96_000_000, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'shares/dev/premium-model.xlsx'],
138
+ ['risk-register.csv', 62_000_000, 'text/csv', 'shares/dev/risk-register.csv'],
139
+ ['broker-pack.pdf', 48_000_000, 'application/pdf', 'shares/dev/broker-pack.pdf'],
140
+ ['loss-runs.zip', 31_000_000, 'application/zip', 'shares/dev/loss-runs.zip'],
141
+ ]
132
142
  }
133
143
  if (!smallFiles.length) {
134
144
  console.log(' no bucket configured: attachments are recorded but hold no bytes')
@@ -163,27 +173,33 @@ async function main() {
163
173
  // Every other heavy file is locked, so both the open and the protected path can be
164
174
  // walked. A real sender tells the recipient the password, so these do too.
165
175
  let passwordNote = ''
166
- if (index < WITH_HEAVY) {
167
- const [filename, size, contentType] = heavyFiles[index]
176
+ if (index < heavyFiles.length) {
177
+ const [filename, size, contentType, objectKey] = heavyFiles[index]
168
178
  const shareId = `devshare${String(index).padStart(2, '0')}`
169
179
  const locked = index % 4 === 0
170
- shares.push([shareId, `shares/dev/${filename}`, filename, contentType, size, locked])
180
+ shares.push([shareId, objectKey, filename, contentType, size, locked])
171
181
  attachments = [{ filename, contentType, size, shareId }]
172
182
  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]
183
+ ? `\n\nThe file is behind a link rather than attached. The password is "${SHARE_PASSWORD}".`
184
+ : '\n\nThe file is behind a link rather than attached. No password is needed.'
185
+ } else if (index < heavyFiles.length + WITH_ATTACHMENTS) {
186
+ const [filename, size, contentType, key] = smallFiles[(index - heavyFiles.length) % smallFiles.length]
177
187
  attachments = [key ? { filename, contentType, size, key } : { filename, contentType, size }]
178
188
  }
179
189
 
190
+ // Deliberately past the first screenful: a folder whose mail all sits below the rows
191
+ // the client happens to hold is exactly the case that used to render an endless
192
+ // skeleton, and there is no way to notice it again if the seed never produces one.
193
+ const trashed = index >= 420 && index % 11 === 0 ? 1 : 0
194
+ const archived = !trashed && index >= 380 && index % 7 === 0 ? 1 : 0
195
+
180
196
  rows.push([
181
197
  id, from, JSON.stringify([DEV_ADDRESS]), '[]', '[]', '[]',
182
198
  `${subject} — ${company}`, '',
183
199
  `${body}${passwordNote}\n\nRegards,\n${company}`,
184
200
  '{}', receivedAt, random() > 0.55 ? 1 : 0,
185
201
  JSON.stringify(attachments),
186
- random() > 0.9 ? 1 : 0, 0, 0, '[]', DEV_ADDRESS,
202
+ random() > 0.9 ? 1 : 0, archived, trashed, '[]', DEV_ADDRESS,
187
203
  ])
188
204
  }
189
205
 
@@ -230,7 +246,34 @@ async function main() {
230
246
  const indexed = await db.execute('SELECT COUNT(*) AS n FROM mail_inbox_fts')
231
247
 
232
248
  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)`)
249
+ console.log(` ${withAttachments.rows[0].n} carry attachments (${shares.length} behind links, ${WITH_ATTACHMENTS} ordinary)`)
250
+ const folders = await db.execute({
251
+ sql: `SELECT SUM(archived = 1 AND trashed = 0) AS archived, SUM(trashed = 1) AS trashed, SUM(starred = 1 AND trashed = 0) AS starred
252
+ FROM mail_inbox WHERE lower(owner) = ?`,
253
+ args: [DEV_ADDRESS],
254
+ })
255
+ const folderRow = folders.rows[0]
256
+ console.log(` folders: ${folderRow.archived} archived, ${folderRow.trashed} in the bin, ${folderRow.starred} starred`)
257
+
258
+ // Two conversations put aside: one that is still waiting, and one whose time has come
259
+ // round, so both sides of "it comes back on its own" are visible without waiting a day.
260
+ const wakesLater = new Date(now + 36 * 3600_000).toISOString()
261
+ const wokeAlready = new Date(now - 2 * 3600_000).toISOString()
262
+ await db.execute({
263
+ sql: `UPDATE mail_inbox SET snoozed_until = ?
264
+ WHERE owner = ? AND id IN ('dev-0040', 'dev-0041', 'dev-0042')`,
265
+ args: [wakesLater, DEV_ADDRESS],
266
+ })
267
+ await db.execute({
268
+ sql: `UPDATE mail_inbox SET snoozed_until = ? WHERE owner = ? AND id = 'dev-0050'`,
269
+ args: [wokeAlready, DEV_ADDRESS],
270
+ })
271
+ const napping = await db.execute({
272
+ sql: `SELECT SUM(snoozed_until > ?) AS waiting, SUM(snoozed_until <= ?) AS woken
273
+ FROM mail_inbox WHERE owner = ? AND snoozed_until IS NOT NULL`,
274
+ args: [new Date(now).toISOString(), new Date(now).toISOString(), DEV_ADDRESS],
275
+ })
276
+ console.log(` snoozed: ${napping.rows[0].waiting} still waiting, ${napping.rows[0].woken} already back`)
234
277
  console.log(` search index rows: ${indexed.rows[0].n}`)
235
278
  }
236
279
 
package/tools/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Tools
2
2
 
3
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
4
+ > `master` auto-deploys the Novacraft mailbox. See the deploying section in the
5
5
  > root [README](../README.md).
6
6
 
7
7
  | File | What it does |