@novacraft-engineering/mailbox 0.4.24 → 0.4.26
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 +8 -2
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/mail/RichEditor.tsx +450 -5
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +115 -12
- package/app/mail/tabKey.ts +52 -0
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +7 -2
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +67 -33
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
package/lib/mail-provider.ts
CHANGED
|
@@ -166,9 +166,8 @@ function buildRawMime(payload: SendPayload): string {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
async function sendViaSes(payload: SendPayload): Promise<SendResult> {
|
|
169
|
-
// buildRawMime deliberately writes no Bcc header; SES is told the blind recipients here.
|
|
170
169
|
const region = process.env.AWS_SES_REGION ?? 'eu-north-1'
|
|
171
|
-
return { id: await sesSendRaw(buildRawMime(payload), region, payload.bcc
|
|
170
|
+
return { id: await sesSendRaw(buildRawMime(payload), region, { to: payload.to, cc: payload.cc, bcc: payload.bcc }) }
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
/**
|
package/lib/mailbox.ts
CHANGED
|
@@ -1313,14 +1313,19 @@ export async function refreshThreadsFrom(
|
|
|
1313
1313
|
return { refreshed: pairs.length, cursor: last ? { owner: String(last.owner), threadId: String(last.thread_id) } : null }
|
|
1314
1314
|
}
|
|
1315
1315
|
|
|
1316
|
+
/** RFC 7489 §7.2.1.1's subject line, and Amazon's variant of it. Nobody types this by hand. */
|
|
1317
|
+
export function isDmarcAggregateReport(subject: string | null | undefined): boolean {
|
|
1318
|
+
return /^(dmarc aggregate )?report domain:[\s\S]*\bsubmitter:/i.test((subject ?? '').trim())
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1316
1321
|
export async function appendInbound(
|
|
1317
1322
|
email: Omit<InboundEmail, 'starred' | 'archived' | 'trashed' | 'labels' | 'threadId'>,
|
|
1318
1323
|
): Promise<void> {
|
|
1319
1324
|
await ensureMailSchema()
|
|
1320
1325
|
const sql = db()
|
|
1321
1326
|
await sql`
|
|
1322
|
-
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta, addressed, risk, risk_reasons, spam)
|
|
1323
|
-
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0})
|
|
1327
|
+
INSERT INTO mail_inbox (id, from_addr, to_addrs, cc, bcc, reply_to, subject, html, body_text, headers, received_at, read, attachments, owner, snippet, thread_meta, attach_meta, addressed, risk, risk_reasons, spam, archived)
|
|
1328
|
+
VALUES (${email.id}, ${email.from}, ${JSON.stringify(email.to)}, ${JSON.stringify(email.cc)}, ${JSON.stringify(email.bcc)}, ${JSON.stringify(email.replyTo)}, ${email.subject}, ${email.html}, ${email.text}, ${JSON.stringify(email.headers)}, ${email.receivedAt}, ${email.read}, ${JSON.stringify(email.attachments)}, ${email.owner ?? null}, ${listSnippet(email.text)}, ${threadMeta(email.headers)}, ${attachMeta(email.attachments)}, ${classifyAddressed(email.owner, email.to, email.cc)}, ${email.risk ?? 'clean'}, ${JSON.stringify(email.riskReasons ?? [])}, ${email.spam ? 1 : 0}, ${isDmarcAggregateReport(email.subject) ? 1 : 0})
|
|
1324
1329
|
ON CONFLICT (id) DO NOTHING`
|
|
1325
1330
|
await threadMessage({ id: email.id, owner: email.owner ?? null, subject: email.subject, receivedAt: email.receivedAt })
|
|
1326
1331
|
await invalidateCounts(email.owner)
|
package/lib/receive.test.ts
CHANGED
|
@@ -23,3 +23,27 @@ test('only mail addressed to a hosted domain is ingested', async () => {
|
|
|
23
23
|
assert.equal(addressedToUs(['x@metroperilinsbrokers.com.evil.net']), false)
|
|
24
24
|
|
|
25
25
|
})
|
|
26
|
+
|
|
27
|
+
test('the To line comes from the header, not from the copy the provider delivered', async () => {
|
|
28
|
+
const { headerAddresses } = await import('./receive.ts')
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
headerAddresses('eric.samuel@coronationinsurance.com.ng, sijuatobatele@lasacoassurance.com'),
|
|
32
|
+
['eric.samuel@coronationinsurance.com.ng', 'sijuatobatele@lasacoassurance.com'],
|
|
33
|
+
)
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
headerAddresses('"Adeolu Ajao" <aajao@custodianinsurance.com>, "Ernest Okpata" <eokpata.metroperil@gmail.com>'),
|
|
36
|
+
['aajao@custodianinsurance.com', 'eokpata.metroperil@gmail.com'],
|
|
37
|
+
)
|
|
38
|
+
assert.deepEqual(headerAddresses('"Doe, John" <john@example.com>, jane@example.com'), ['john@example.com', 'jane@example.com'], 'a comma inside a quoted name does not split it')
|
|
39
|
+
assert.deepEqual(headerAddresses('"Say \\"hi\\", please" <quote@example.com>'), ['quote@example.com'], 'an escaped quote does not end the name')
|
|
40
|
+
assert.deepEqual(headerAddresses('undisclosed-recipients:;'), [], 'a blind copy has no visible recipient')
|
|
41
|
+
assert.deepEqual(headerAddresses('team: a@example.com, b@example.com;'), ['a@example.com', 'b@example.com'])
|
|
42
|
+
assert.deepEqual(headerAddresses(['a@example.com', '"B" <b@example.com>']), ['a@example.com', 'b@example.com'])
|
|
43
|
+
assert.deepEqual(headerAddresses('john@x.com (John)'), ['john@x.com'], 'a trailing comment is not part of the address')
|
|
44
|
+
assert.deepEqual(headerAddresses('john@x.com (Smith, John), k@x.com'), ['john@x.com', 'k@x.com'], 'a comma inside a comment does not split')
|
|
45
|
+
assert.deepEqual(headerAddresses('"Ops <ops@old.com>" <real@x.com>, k@x.com'), ['real@x.com', 'k@x.com'], 'brackets inside a quoted name are not the address')
|
|
46
|
+
assert.deepEqual(headerAddresses('Jo (a "b) <j@x.com>, k@x.com'), ['j@x.com', 'k@x.com'])
|
|
47
|
+
assert.deepEqual(headerAddresses(undefined), [])
|
|
48
|
+
assert.deepEqual(headerAddresses(''), [])
|
|
49
|
+
})
|
package/lib/receive.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
|
|
2
2
|
import { sendPush } from '@/lib/push'
|
|
3
3
|
import { stripOwnPixel } from '@/lib/email-html'
|
|
4
|
-
import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
-
import { ADDRESS_ALIASES, appendInbound, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
4
|
+
import { FORWARDING_ENABLED, FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
+
import { ADDRESS_ALIASES, appendInbound, isDmarcAggregateReport, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
6
6
|
import { sendMail } from '@/lib/mail-provider'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -66,6 +66,45 @@ function escapeHtml(src: string): string {
|
|
|
66
66
|
return src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The addresses in a To or Cc header, bare, in the shape the provider already gives Cc.
|
|
71
|
+
*
|
|
72
|
+
* The provider's own `to` is the envelope recipient of the one copy it delivered, so every
|
|
73
|
+
* mailbox on a message saw itself in the To line — a colleague copied in read as though the
|
|
74
|
+
* client had written to them.
|
|
75
|
+
*/
|
|
76
|
+
export function headerAddresses(value: unknown): string[] {
|
|
77
|
+
const raw = Array.isArray(value) ? value.join(', ') : typeof value === 'string' ? value : ''
|
|
78
|
+
const entries: string[] = []
|
|
79
|
+
let current = ''
|
|
80
|
+
let quoted = false
|
|
81
|
+
let angled = false
|
|
82
|
+
let comment = 0
|
|
83
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
84
|
+
const char = raw[index]
|
|
85
|
+
if ((quoted || comment) && char === '\\') {
|
|
86
|
+
current += char + (raw[index + 1] ?? '')
|
|
87
|
+
index += 1
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (char === '"' && !comment) quoted = !quoted
|
|
91
|
+
else if (!quoted && char === '(') comment += 1
|
|
92
|
+
else if (!quoted && char === ')' && comment) comment -= 1
|
|
93
|
+
else if (!quoted && !comment && char === '<') angled = true
|
|
94
|
+
else if (!quoted && !comment && char === '>') angled = false
|
|
95
|
+
if ((char === ',' || char === ';') && !quoted && !angled && !comment) {
|
|
96
|
+
entries.push(current)
|
|
97
|
+
current = ''
|
|
98
|
+
} else current += char
|
|
99
|
+
}
|
|
100
|
+
entries.push(current)
|
|
101
|
+
return entries
|
|
102
|
+
.map(entry => entry.replace(/"(?:[^"\\]|\\.)*"/g, ' ').replace(/\((?:[^()\\]|\\.)*\)/g, ' '))
|
|
103
|
+
.map(entry => entry.replace(/^[^<]*?:/, ''))
|
|
104
|
+
.map(entry => (entry.match(/<([^>]*)>/)?.[1] ?? entry).trim())
|
|
105
|
+
.filter(address => /^[^\s@<>"()]+@[^\s@<>"()]+$/.test(address))
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
export type ReceivedEmail = {
|
|
70
109
|
html: string | null
|
|
71
110
|
text: string | null
|
|
@@ -189,6 +228,7 @@ export async function forwardToAccounts(
|
|
|
189
228
|
ownerAddress: string | null,
|
|
190
229
|
files?: SendAttachment[],
|
|
191
230
|
): Promise<void> {
|
|
231
|
+
if (!FORWARDING_ENABLED || isDmarcAggregateReport(inbound.subject)) return
|
|
192
232
|
const owningAccount = ownerAddress ? await getAccountByAddress(ownerAddress) : null
|
|
193
233
|
const recipient = owningAccount?.email ?? FORWARD_RECIPIENTS[0] // fallback: the admin
|
|
194
234
|
if (!recipient) return
|
|
@@ -265,6 +305,7 @@ export async function ingestReceived(
|
|
|
265
305
|
...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
266
306
|
].filter(Boolean)
|
|
267
307
|
const owner = await attributeOwner(recipients)
|
|
308
|
+
const toHeader = (full?.headers as Record<string, unknown> | undefined)?.to
|
|
268
309
|
const fromAddress = full?.from || String(data.from ?? '')
|
|
269
310
|
const senderDomain = senderDomainOf(fromAddress)
|
|
270
311
|
const standing = await senderStanding(owner, senderDomain)
|
|
@@ -293,7 +334,9 @@ export async function ingestReceived(
|
|
|
293
334
|
// Held out of the inbox, not merely labelled, once the weight passes the threshold.
|
|
294
335
|
spam: verdicts.quarantine,
|
|
295
336
|
from: full?.from || String(data.from ?? ''),
|
|
296
|
-
to:
|
|
337
|
+
to: toHeader !== undefined || Object.keys(full?.headers ?? {}).length
|
|
338
|
+
? headerAddresses(toHeader)
|
|
339
|
+
: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
297
340
|
cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
|
|
298
341
|
bcc: full?.bcc ?? (Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
299
342
|
replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
|
|
@@ -320,9 +363,8 @@ export async function ingestReceived(
|
|
|
320
363
|
// or forwarded. Without this, every message the provider account receives for any tenant
|
|
321
364
|
// lands in whichever deployment holds the webhook, filed to its shared inbox because no
|
|
322
365
|
// local account matches — one tenant reading another's mail.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
throw new Error(`refusing ${emailId}: addressed to ${envelope.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
|
|
366
|
+
if (!addressedToUs(recipients)) {
|
|
367
|
+
throw new Error(`refusing ${emailId}: addressed to ${recipients.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
|
|
326
368
|
}
|
|
327
369
|
|
|
328
370
|
// Pull the bytes once: they become our stored copy and the forward's attachments.
|
|
@@ -341,11 +383,13 @@ export async function ingestReceived(
|
|
|
341
383
|
await noteSender(owner, senderDomain, 'received').catch(() => {})
|
|
342
384
|
const sender = parseSender(inbound.from)
|
|
343
385
|
await recordContact(sender.email, sender.name)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
386
|
+
if (!isDmarcAggregateReport(inbound.subject)) {
|
|
387
|
+
await sendPush(inbound.owner, {
|
|
388
|
+
title: sender.name || sender.email || 'New mail',
|
|
389
|
+
body: inbound.subject,
|
|
390
|
+
tag: emailId,
|
|
391
|
+
}).catch(() => {})
|
|
392
|
+
}
|
|
349
393
|
await forwardToAccounts(
|
|
350
394
|
emailId,
|
|
351
395
|
full ?? {
|
package/lib/scheduled.ts
CHANGED
|
@@ -124,11 +124,49 @@ const MAX_ATTEMPTS = 5
|
|
|
124
124
|
* Hands over every message whose time has come. Each row is claimed with a conditional
|
|
125
125
|
* update before it is sent, so two runners overlapping cannot send the same mail twice.
|
|
126
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* Sends one queued message, if it is still waiting. The claim is a single conditional
|
|
129
|
+
* update, so whichever caller gets there first sends it and every other caller — the
|
|
130
|
+
* minute cron, the page that watched its undo window close — finds nothing to do.
|
|
131
|
+
*/
|
|
132
|
+
async function dispatchRow(row: Record<string, unknown>): Promise<'sent' | 'failed' | 'skipped'> {
|
|
133
|
+
const sql = db()
|
|
134
|
+
const id = String(row.id)
|
|
135
|
+
const claimed = await sql`
|
|
136
|
+
UPDATE mail_scheduled SET status = 'sending' WHERE id = ${id} AND status = 'pending' RETURNING id`
|
|
137
|
+
if (!claimed.length) return 'skipped'
|
|
138
|
+
|
|
139
|
+
const send = parse(row.payload)
|
|
140
|
+
if (!send) {
|
|
141
|
+
await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
|
|
142
|
+
return 'failed'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
147
|
+
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
148
|
+
const result = await sendMail(
|
|
149
|
+
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
150
|
+
)
|
|
151
|
+
await recordSend(send, result?.id ?? null)
|
|
152
|
+
await sql`
|
|
153
|
+
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
|
154
|
+
return 'sent'
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const attempts = Number(row.attempts ?? 0) + 1
|
|
157
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
158
|
+
await sql`
|
|
159
|
+
UPDATE mail_scheduled
|
|
160
|
+
SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
|
|
161
|
+
WHERE id = ${id}`
|
|
162
|
+
return 'failed'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
127
166
|
export async function dispatchDue(limit = 25): Promise<{ due: number; sent: number; failed: number }> {
|
|
128
167
|
await ensureMailSchema()
|
|
129
|
-
const sql = db()
|
|
130
168
|
const now = new Date().toISOString()
|
|
131
|
-
const due = await
|
|
169
|
+
const due = await db()`
|
|
132
170
|
SELECT id, payload, attempts FROM mail_scheduled
|
|
133
171
|
WHERE status = 'pending' AND send_after <= ${now}
|
|
134
172
|
ORDER BY send_after LIMIT ${limit}`
|
|
@@ -136,37 +174,33 @@ export async function dispatchDue(limit = 25): Promise<{ due: number; sent: numb
|
|
|
136
174
|
let sent = 0
|
|
137
175
|
let failed = 0
|
|
138
176
|
for (const row of due) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (!claimed.length) continue
|
|
143
|
-
|
|
144
|
-
const send = parse(row.payload)
|
|
145
|
-
if (!send) {
|
|
146
|
-
await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
|
|
147
|
-
failed += 1
|
|
148
|
-
continue
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
153
|
-
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
154
|
-
const result = await sendMail(
|
|
155
|
-
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
156
|
-
)
|
|
157
|
-
await recordSend(send, result?.id ?? null)
|
|
158
|
-
await sql`
|
|
159
|
-
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
|
160
|
-
sent += 1
|
|
161
|
-
} catch (err) {
|
|
162
|
-
const attempts = Number(row.attempts ?? 0) + 1
|
|
163
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
164
|
-
await sql`
|
|
165
|
-
UPDATE mail_scheduled
|
|
166
|
-
SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
|
|
167
|
-
WHERE id = ${id}`
|
|
168
|
-
failed += 1
|
|
169
|
-
}
|
|
177
|
+
const outcome = await dispatchRow(row)
|
|
178
|
+
if (outcome === 'sent') sent += 1
|
|
179
|
+
if (outcome === 'failed') failed += 1
|
|
170
180
|
}
|
|
171
181
|
return { due: due.length, sent, failed }
|
|
172
182
|
}
|
|
183
|
+
|
|
184
|
+
// How early a message may be sent on its owner's say-so. It covers a page clock running a
|
|
185
|
+
// little ahead of ours, and no more: a message scheduled for tomorrow is not sent now
|
|
186
|
+
// because a page asked, which is what "Send now" and its explicit reschedule are for.
|
|
187
|
+
const EARLY_TOLERANCE_MS = 30_000
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The owner's page watched the undo window close. Sending then, rather than at the next
|
|
191
|
+
* minute cron, is what makes "Sent" true when the page says it. Already sent counts as
|
|
192
|
+
* sent: the cron may have got there first.
|
|
193
|
+
*/
|
|
194
|
+
export async function dispatchNow(id: string, owner: string): Promise<'sent' | 'already-sent' | 'not-due' | 'failed' | 'missing'> {
|
|
195
|
+
await ensureMailSchema()
|
|
196
|
+
const rows = await db()`
|
|
197
|
+
SELECT id, payload, attempts, status, send_after FROM mail_scheduled
|
|
198
|
+
WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()}`
|
|
199
|
+
const row = rows[0]
|
|
200
|
+
if (!row) return 'missing'
|
|
201
|
+
if (row.status === 'sent' || row.status === 'sending') return 'already-sent'
|
|
202
|
+
if (row.status !== 'pending') return 'failed'
|
|
203
|
+
if (Date.parse(String(row.send_after)) > Date.now() + EARLY_TOLERANCE_MS) return 'not-due'
|
|
204
|
+
const outcome = await dispatchRow(row)
|
|
205
|
+
return outcome === 'skipped' ? 'already-sent' : outcome
|
|
206
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { sesDestination } from './ses-send.ts'
|
|
3
|
+
|
|
4
|
+
// The bug: with a blind copy present, only the blind copy was handed to SES, and SES
|
|
5
|
+
// delivers a raw message to its Destination alone — the real recipients were dropped.
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
sesDestination({ to: ['rokoeman@example.com'], bcc: ['automated@example.org'] }),
|
|
8
|
+
{ ToAddresses: ['rokoeman@example.com'], BccAddresses: ['automated@example.org'] },
|
|
9
|
+
)
|
|
10
|
+
assert.deepEqual(
|
|
11
|
+
sesDestination({ to: ['Robert <robert@example.com>'], cc: ['"Ops, Team" <ops@example.com>'] }),
|
|
12
|
+
{ ToAddresses: ['robert@example.com'], CcAddresses: ['ops@example.com'] },
|
|
13
|
+
'display names stay in the headers; the envelope carries bare addresses',
|
|
14
|
+
)
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
sesDestination({ to: ['a@example.com'], cc: ['A@example.com'], bcc: ['a@example.com', 'b@example.com'] }),
|
|
17
|
+
{ ToAddresses: ['a@example.com'], BccAddresses: ['b@example.com'] },
|
|
18
|
+
'an address named twice is delivered once',
|
|
19
|
+
)
|
|
20
|
+
assert.deepEqual(sesDestination({ to: ['a@example.com'] }), { ToAddresses: ['a@example.com'] })
|
|
21
|
+
console.log('ok - ses envelope carries every recipient')
|
package/lib/ses-send.ts
CHANGED
|
@@ -7,15 +7,39 @@ export function sesConfigured(): boolean {
|
|
|
7
7
|
return Boolean(process.env.SES_ACCESS_KEY_ID && process.env.SES_SECRET_ACCESS_KEY)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export type SesRecipients = { to: string[]; cc?: string[]; bcc?: string[] }
|
|
11
|
+
|
|
12
|
+
const addressOnly = (entry: string): string => (entry.match(/<([^>]+)>/)?.[1] ?? entry).trim()
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
|
-
*
|
|
12
|
-
* to
|
|
13
|
-
*
|
|
15
|
+
* The envelope, spelled out in full. Once a raw message names any Destination, SES delivers
|
|
16
|
+
* to that list and nothing else — the To and Cc headers inside the MIME are not added to it —
|
|
17
|
+
* so naming only the blind copies sent the message to the blind copies alone. Bcc never goes
|
|
18
|
+
* in the MIME, where every recipient could read it; it lives here and only here.
|
|
14
19
|
*/
|
|
20
|
+
export function sesDestination(recipients: SesRecipients) {
|
|
21
|
+
const seen = new Set<string>()
|
|
22
|
+
const take = (list: string[] | undefined) =>
|
|
23
|
+
(list ?? []).map(addressOnly).filter(address => {
|
|
24
|
+
const key = address.toLowerCase()
|
|
25
|
+
if (!address || seen.has(key)) return false
|
|
26
|
+
seen.add(key)
|
|
27
|
+
return true
|
|
28
|
+
})
|
|
29
|
+
const to = take(recipients.to)
|
|
30
|
+
const cc = take(recipients.cc)
|
|
31
|
+
const bcc = take(recipients.bcc)
|
|
32
|
+
return {
|
|
33
|
+
...(to.length ? { ToAddresses: to } : {}),
|
|
34
|
+
...(cc.length ? { CcAddresses: cc } : {}),
|
|
35
|
+
...(bcc.length ? { BccAddresses: bcc } : {}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export async function sesSendRaw(
|
|
16
40
|
raw: string,
|
|
17
41
|
region = process.env.AWS_SES_REGION ?? 'eu-north-1',
|
|
18
|
-
|
|
42
|
+
recipients: SesRecipients,
|
|
19
43
|
): Promise<string | null> {
|
|
20
44
|
const id = process.env.SES_ACCESS_KEY_ID
|
|
21
45
|
const secret = process.env.SES_SECRET_ACCESS_KEY
|
|
@@ -25,7 +49,7 @@ export async function sesSendRaw(
|
|
|
25
49
|
const path = '/v2/email/outbound-emails'
|
|
26
50
|
const body = JSON.stringify({
|
|
27
51
|
Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } },
|
|
28
|
-
|
|
52
|
+
Destination: sesDestination(recipients),
|
|
29
53
|
})
|
|
30
54
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
31
55
|
const stamp = amzDate.slice(0, 8)
|
package/lib/sns.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createPublicKey, createVerify } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export type SnsEnvelope = {
|
|
4
|
+
Type: string
|
|
5
|
+
MessageId?: string
|
|
6
|
+
TopicArn?: string
|
|
7
|
+
Subject?: string
|
|
8
|
+
Message?: string
|
|
9
|
+
Timestamp?: string
|
|
10
|
+
Token?: string
|
|
11
|
+
SubscribeURL?: string
|
|
12
|
+
SignatureVersion?: string
|
|
13
|
+
Signature?: string
|
|
14
|
+
SigningCertURL?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SIGNED_FIELDS: Record<string, string[]> = {
|
|
18
|
+
Notification: ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'],
|
|
19
|
+
SubscriptionConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'],
|
|
20
|
+
UnsubscribeConfirmation: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A certificate from anywhere but AWS proves nothing about who signed the message. */
|
|
24
|
+
export function awsSnsUrl(raw: string, suffix: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(raw)
|
|
27
|
+
return url.protocol === 'https:' && url.pathname.endsWith(suffix) && /^sns\.[a-z0-9-]+\.amazonaws\.com$/.test(url.hostname)
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const certCache = new Map<string, string>()
|
|
34
|
+
|
|
35
|
+
async function fetchCert(url: string): Promise<string> {
|
|
36
|
+
const cached = certCache.get(url)
|
|
37
|
+
if (cached) return cached
|
|
38
|
+
const response = await fetch(url)
|
|
39
|
+
if (!response.ok) throw new Error(`certificate fetch failed (${response.status})`)
|
|
40
|
+
const pem = await response.text()
|
|
41
|
+
certCache.set(url, pem)
|
|
42
|
+
return pem
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True only for a message AWS signed for `topicArn`. The topic is not optional: any AWS
|
|
47
|
+
* account can create a topic, subscribe this URL to it and send messages SNS signs
|
|
48
|
+
* perfectly well, so a valid signature alone says nothing about whose topic it came from.
|
|
49
|
+
*/
|
|
50
|
+
export async function verifySns(envelope: SnsEnvelope, topicArn: string | undefined): Promise<boolean> {
|
|
51
|
+
if (!topicArn || envelope.TopicArn !== topicArn) return false
|
|
52
|
+
if (!envelope.Signature || !envelope.SigningCertURL || !awsSnsUrl(envelope.SigningCertURL, '.pem')) return false
|
|
53
|
+
const fields = SIGNED_FIELDS[envelope.Type]
|
|
54
|
+
if (!fields) return false
|
|
55
|
+
let canonical = ''
|
|
56
|
+
for (const field of fields) {
|
|
57
|
+
const value = (envelope as Record<string, unknown>)[field]
|
|
58
|
+
if (value !== undefined && value !== null) canonical += `${field}\n${String(value)}\n`
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const verifier = createVerify(envelope.SignatureVersion === '2' ? 'RSA-SHA256' : 'RSA-SHA1')
|
|
62
|
+
verifier.update(canonical, 'utf8')
|
|
63
|
+
verifier.end()
|
|
64
|
+
return verifier.verify(createPublicKey(await fetchCert(envelope.SigningCertURL)), Buffer.from(envelope.Signature, 'base64'))
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.26",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"webmail",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@tiptap/react": "^3.31.3",
|
|
52
52
|
"@tiptap/starter-kit": "^3.31.3",
|
|
53
53
|
"next": "^16.3.5",
|
|
54
|
+
"nspell": "2.1.5",
|
|
54
55
|
"react": "19.3.0",
|
|
55
56
|
"react-dom": "19.3.0",
|
|
56
57
|
"resend": "^6.12.3",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@types/node": "^22",
|
|
62
|
+
"@types/nspell": "2.1.6",
|
|
61
63
|
"@types/react": "^19",
|
|
62
64
|
"@types/react-dom": "^19",
|
|
63
65
|
"@types/web-push": "^3.6.4",
|