@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.
- package/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +23 -4
- package/app/mail/RichEditor.tsx +167 -53
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +643 -76
- package/app/mail/page.tsx +1062 -247
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/scripts/sample-files.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
48
|
-
function
|
|
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,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
package/scripts/seed-dev.mjs
CHANGED
|
@@ -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 <
|
|
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,
|
|
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
|
|
174
|
-
: '\n\nThe file is
|
|
175
|
-
} else if (index <
|
|
176
|
-
const [filename, size, contentType, key] = smallFiles[(index -
|
|
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,
|
|
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 (${
|
|
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
|
|
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 |
|