@novacraft-engineering/mailbox 0.4.5 → 0.4.17
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 +17 -0
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +35 -3
- package/app/api/mail/threads/route.ts +1 -1
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +195 -1
- package/app/mail/page.tsx +274 -35
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/lib/dev-auth.ts +25 -9
- package/lib/mail-provider.ts +22 -2
- package/lib/mailbox.ts +514 -42
- package/lib/receive.test.ts +20 -8
- package/lib/receive.ts +109 -12
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
package/lib/receive.test.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
process.env.MAIL_ADDRESS_DOMAIN = 'metroperilinsbrokers.com'
|
|
4
|
+
process.env.MAIL_ADDRESS_DOMAINS = 'metroperilinsbrokers.com'
|
|
5
|
+
|
|
2
6
|
import test from 'node:test'
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
|
|
8
|
+
test('only mail addressed to a hosted domain is ingested', async () => {
|
|
9
|
+
const { addressedToUs } = await import('./receive.ts')
|
|
10
|
+
|
|
11
|
+
assert.equal(addressedToUs(['info@metroperilinsbrokers.com']), true)
|
|
12
|
+
assert.equal(addressedToUs(['O. Komolafe <okomolafe@metroperilinsbrokers.com>']), true, 'a display name does not hide the domain')
|
|
13
|
+
assert.equal(addressedToUs(['someone@example.com', 'info@metroperilinsbrokers.com']), true, 'one of ours is enough')
|
|
14
|
+
|
|
15
|
+
// The leak: one provider account held several tenants' domains and posted all of their
|
|
16
|
+
// mail to this one webhook, which filed it to the shared inbox because no local account
|
|
17
|
+
// matched. 360 of another tenant's messages became readable here.
|
|
18
|
+
assert.equal(addressedToUs(['hello@contact.novacraft.africa']), false)
|
|
19
|
+
assert.equal(addressedToUs(['admin@jakstoc.com', 'info@podzyme.com']), false)
|
|
20
|
+
assert.equal(addressedToUs([]), false, 'no recipient is not our recipient')
|
|
21
|
+
// A domain that merely ends with ours is a different domain.
|
|
22
|
+
assert.equal(addressedToUs(['x@evil-metroperilinsbrokers.com']), false)
|
|
23
|
+
assert.equal(addressedToUs(['x@metroperilinsbrokers.com.evil.net']), false)
|
|
6
24
|
|
|
7
|
-
test('every domain we receive on is refused as a forward target, not just the primary one', async () => {
|
|
8
|
-
const { ADDRESS_DOMAINS } = await import('./brand.ts')
|
|
9
|
-
const loops = (address: string) => ADDRESS_DOMAINS.includes(address.split('@')[1] ?? '')
|
|
10
|
-
assert.equal(loops('hosting@example.com'), true, 'a secondary domain we receive on would loop')
|
|
11
|
-
assert.equal(loops('hello@contact.example.com'), true, 'the primary domain would loop')
|
|
12
|
-
assert.equal(loops('someone@gmail.com'), false, 'an outside address is a real destination')
|
|
13
25
|
})
|
package/lib/receive.ts
CHANGED
|
@@ -2,9 +2,20 @@ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
|
|
|
2
2
|
import { sendPush } from '@/lib/push'
|
|
3
3
|
import { stripOwnPixel } from '@/lib/email-html'
|
|
4
4
|
import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
|
|
5
|
-
import { ADDRESS_ALIASES, appendInbound, getAccountByAddress, inboundExists, recordContact, recordSentMeta } from '@/lib/mailbox'
|
|
5
|
+
import { ADDRESS_ALIASES, appendInbound, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
|
|
6
6
|
import { sendMail } from '@/lib/mail-provider'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Where the receiving API lives. The Resend SDK reads RESEND_BASE_URL for sending, so a
|
|
10
|
+
* deployment pointed at another Resend-compatible host sends there — these calls are hand
|
|
11
|
+
* rolled and have to honour the same variable, or receiving silently talks to a host that
|
|
12
|
+
* has never seen the message and every body arrives empty.
|
|
13
|
+
*/
|
|
14
|
+
function receivingBase(): string {
|
|
15
|
+
const configured = (process.env.RESEND_BASE_URL ?? '').trim().replace(/\/+$/, '')
|
|
16
|
+
return configured || 'https://api.resend.com'
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
// The address we send from, and the inbox that owns mail addressed to nobody specific.
|
|
9
20
|
const MAIL_FROM = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
|
|
10
21
|
const SHARED_ADDRESS = MAIL_FROM.toLowerCase()
|
|
@@ -24,6 +35,22 @@ export async function attributeOwner(recipients: string[]): Promise<string> {
|
|
|
24
35
|
return SHARED_ADDRESS
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Whether any recipient is on a domain this deployment hosts. One provider account can
|
|
40
|
+
* hold several tenants' domains and posts every message it receives to the one webhook it
|
|
41
|
+
* is configured with, so a deployment is offered other tenants' mail as a matter of course
|
|
42
|
+
* — and a signature check proves only that the provider sent it, not that it is ours.
|
|
43
|
+
*/
|
|
44
|
+
export function addressedToUs(recipients: string[]): boolean {
|
|
45
|
+
const hosted = new Set([...ADDRESS_DOMAINS, MAIL_DOMAIN].map(domain => domain.trim().toLowerCase()).filter(Boolean))
|
|
46
|
+
return recipients.some(raw => {
|
|
47
|
+
const match = raw?.match(/<([^>]+)>/)
|
|
48
|
+
const address = (match ? match[1] : raw ?? '').trim().toLowerCase()
|
|
49
|
+
const domain = address.split('@')[1] ?? ''
|
|
50
|
+
return domain !== '' && hosted.has(domain)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
export function parseSender(raw: string): { email: string; name: string | null } {
|
|
28
55
|
const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
|
|
29
56
|
if (match) return { name: match[1].replace(/^"|"$/g, '') || null, email: match[2].trim() }
|
|
@@ -57,7 +84,7 @@ export type ReceivedEmail = {
|
|
|
57
84
|
// lives on Resend and must be pulled from the receiving endpoint, or the inbox stores blanks.
|
|
58
85
|
export async function fetchReceivedEmail(emailId: string, apiKey: string): Promise<ReceivedEmail | null> {
|
|
59
86
|
try {
|
|
60
|
-
const response = await fetch(
|
|
87
|
+
const response = await fetch(`${receivingBase()}/emails/receiving/${encodeURIComponent(emailId)}`, {
|
|
61
88
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
62
89
|
})
|
|
63
90
|
if (!response.ok) return null
|
|
@@ -118,7 +145,7 @@ async function rehostAttachments(
|
|
|
118
145
|
|
|
119
146
|
async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<SendAttachment[]> {
|
|
120
147
|
try {
|
|
121
|
-
const response = await fetch(
|
|
148
|
+
const response = await fetch(`${receivingBase()}/emails/receiving/${encodeURIComponent(emailId)}/attachments`, {
|
|
122
149
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
123
150
|
})
|
|
124
151
|
if (!response.ok) return []
|
|
@@ -135,8 +162,13 @@ async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<Se
|
|
|
135
162
|
size: Number(entry.size ?? 0) || undefined,
|
|
136
163
|
}
|
|
137
164
|
try {
|
|
138
|
-
|
|
139
|
-
|
|
165
|
+
// Inline base64 when the host sends the bytes with the listing; a download_url is
|
|
166
|
+
// fetched only when it doesn't.
|
|
167
|
+
if (entry.content) file.content = String(entry.content)
|
|
168
|
+
else {
|
|
169
|
+
const binary = url ? await fetch(url) : null
|
|
170
|
+
if (binary?.ok) file.content = Buffer.from(await binary.arrayBuffer()).toString('base64')
|
|
171
|
+
}
|
|
140
172
|
} catch (err) {
|
|
141
173
|
console.warn(`[mail] attachment pull failed for ${emailId}/${file.filename}:`, err)
|
|
142
174
|
}
|
|
@@ -180,6 +212,8 @@ export async function forwardToAccounts(
|
|
|
180
212
|
to: [recipient],
|
|
181
213
|
replyTo: inbound.from || undefined,
|
|
182
214
|
subject: inbound.subject.startsWith('Fwd:') ? inbound.subject : `Fwd: ${inbound.subject}`,
|
|
215
|
+
// The archive already holds the message this forwards; a second copy is noise.
|
|
216
|
+
skipArchive: true,
|
|
183
217
|
text: inbound.text?.trim() || snippet(inbound.html, inbound.text) || ' ',
|
|
184
218
|
...(inbound.html ? { html: `${header}${stripOwnPixel(inbound.html)}` } : {}),
|
|
185
219
|
...(attachments.length ? { attachments } : {}),
|
|
@@ -199,14 +233,65 @@ export async function forwardToAccounts(
|
|
|
199
233
|
export async function ingestReceived(
|
|
200
234
|
emailId: string,
|
|
201
235
|
data: Record<string, unknown> = {},
|
|
236
|
+
/**
|
|
237
|
+
* Repairing a message already in the mailbox: fill in the body and attachments it was
|
|
238
|
+
* stored without, and stop there. The owner was notified and the copy forwarded when it
|
|
239
|
+
* first arrived, so doing either again would be a second delivery of old mail.
|
|
240
|
+
*/
|
|
241
|
+
mode: 'store' | 'repair' = 'store',
|
|
202
242
|
): Promise<{ owner: string; subject: string; from: string }> {
|
|
203
243
|
const apiKey = process.env.RESEND_API_KEY
|
|
204
244
|
// The webhook payload has no body — pull the full email (html/text/attachments) from Resend.
|
|
205
245
|
const full = apiKey ? await fetchReceivedEmail(emailId, apiKey) : null
|
|
206
246
|
|
|
207
247
|
const payloadAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
|
|
248
|
+
// The sender's own domain answers these, and the provider scans for spam and viruses
|
|
249
|
+
// before we ever see the message. Where the webhook does not carry a verdict, the
|
|
250
|
+
// Authentication-Results header the receiving server wrote does.
|
|
251
|
+
const authHeader = String(
|
|
252
|
+
(full?.headers as Record<string, unknown> | undefined)?.['authentication-results'] ?? '',
|
|
253
|
+
).toLowerCase()
|
|
254
|
+
const fromHeader = (mech: string): string | null => authHeader.match(new RegExp(`${mech}=(\\w+)`))?.[1] ?? null
|
|
255
|
+
|
|
256
|
+
// Who this is for has to be settled before the message can be judged: standing is what
|
|
257
|
+
// this mailbox has done with this sender, and a different mailbox may have done the
|
|
258
|
+
// opposite. Resolved once here and reused below.
|
|
259
|
+
const recipients = [
|
|
260
|
+
...(Array.isArray(full?.to) ? full!.to : []),
|
|
261
|
+
...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
262
|
+
...(Array.isArray(full?.cc) ? full!.cc : []),
|
|
263
|
+
...(Array.isArray(data.cc) ? (data.cc as string[]) : []),
|
|
264
|
+
...(Array.isArray(full?.bcc) ? full!.bcc : []),
|
|
265
|
+
...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
|
|
266
|
+
].filter(Boolean)
|
|
267
|
+
const owner = await attributeOwner(recipients)
|
|
268
|
+
const fromAddress = full?.from || String(data.from ?? '')
|
|
269
|
+
const senderDomain = senderDomainOf(fromAddress)
|
|
270
|
+
const standing = await senderStanding(owner, senderDomain)
|
|
271
|
+
|
|
272
|
+
const verdicts = judgeMessage({
|
|
273
|
+
spam: data.spam == null ? null : String(data.spam),
|
|
274
|
+
virus: data.virus == null ? null : String(data.virus),
|
|
275
|
+
// Where no provider hands us a verdict — a deployment reading its mail through Resend,
|
|
276
|
+
// say — the receiving server's own Authentication-Results header carries the same answer.
|
|
277
|
+
spf: data.spf == null ? fromHeader('spf') : String(data.spf),
|
|
278
|
+
dkim: data.dkim == null ? fromHeader('dkim') : String(data.dkim),
|
|
279
|
+
dmarc: data.dmarc == null ? fromHeader('dmarc') : String(data.dmarc),
|
|
280
|
+
from: full?.from || String(data.from ?? ''),
|
|
281
|
+
replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
|
|
282
|
+
subject: full?.subject || String(data.subject ?? ''),
|
|
283
|
+
text: full?.text ?? (data.text ? String(data.text) : null),
|
|
284
|
+
}, standing)
|
|
285
|
+
if (verdicts.risk !== 'clean') {
|
|
286
|
+
console.warn(`[mail] ${verdicts.risk} inbound ${emailId}:`, verdicts.reasons.join('; '))
|
|
287
|
+
}
|
|
288
|
+
|
|
208
289
|
const inbound = {
|
|
209
290
|
id: emailId,
|
|
291
|
+
risk: verdicts.risk,
|
|
292
|
+
riskReasons: verdicts.reasons,
|
|
293
|
+
// Held out of the inbox, not merely labelled, once the weight passes the threshold.
|
|
294
|
+
spam: verdicts.quarantine,
|
|
210
295
|
from: full?.from || String(data.from ?? ''),
|
|
211
296
|
to: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
212
297
|
cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
|
|
@@ -229,19 +314,31 @@ export async function ingestReceived(
|
|
|
229
314
|
// Attribute (and therefore forward) to the accessor actually addressed — to, cc, OR bcc.
|
|
230
315
|
// Anything not matching a member's personal address stays in the shared (admin) inbox,
|
|
231
316
|
// so a member never receives mail they aren't a party to.
|
|
232
|
-
owner
|
|
233
|
-
...(Array.isArray(full?.to) ? full!.to : []),
|
|
234
|
-
...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
|
|
235
|
-
...(full?.cc ?? []),
|
|
236
|
-
...(full?.bcc ?? []),
|
|
237
|
-
]),
|
|
317
|
+
owner,
|
|
238
318
|
}
|
|
319
|
+
// Refuse mail that was never addressed to this mailbox, before a copy is stored, pushed
|
|
320
|
+
// or forwarded. Without this, every message the provider account receives for any tenant
|
|
321
|
+
// lands in whichever deployment holds the webhook, filed to its shared inbox because no
|
|
322
|
+
// local account matches — one tenant reading another's mail.
|
|
323
|
+
const envelope = [...inbound.to, ...inbound.cc, ...inbound.bcc]
|
|
324
|
+
if (!addressedToUs(envelope)) {
|
|
325
|
+
throw new Error(`refusing ${emailId}: addressed to ${envelope.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
|
|
326
|
+
}
|
|
327
|
+
|
|
239
328
|
// Pull the bytes once: they become our stored copy and the forward's attachments.
|
|
240
329
|
const files = apiKey ? await fetchAttachmentBytes(emailId, apiKey) : []
|
|
241
330
|
if (files.length) inbound.attachments = await rehostAttachments(emailId, files)
|
|
242
331
|
const forwardable = files.filter(file => file.content)
|
|
243
332
|
|
|
333
|
+
if (mode === 'repair') {
|
|
334
|
+
if (!full) throw new Error('provider returned no body; nothing to repair with')
|
|
335
|
+
const filled = await repairInbound(inbound)
|
|
336
|
+
if (!filled) throw new Error('no such message in this mailbox')
|
|
337
|
+
return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
|
|
338
|
+
}
|
|
339
|
+
|
|
244
340
|
await appendInbound(inbound)
|
|
341
|
+
await noteSender(owner, senderDomain, 'received').catch(() => {})
|
|
245
342
|
const sender = parseSender(inbound.from)
|
|
246
343
|
await recordContact(sender.email, sender.name)
|
|
247
344
|
await sendPush(inbound.owner, {
|
|
@@ -283,7 +380,7 @@ export async function listReceived(apiKey: string, since: Date): Promise<Receive
|
|
|
283
380
|
for (let page = 0; page < 200; page += 1) {
|
|
284
381
|
const params = new URLSearchParams({ limit: '50' })
|
|
285
382
|
if (after) params.set('after', after)
|
|
286
|
-
const response = await fetch(
|
|
383
|
+
const response = await fetch(`${receivingBase()}/emails/receiving?${params}`, {
|
|
287
384
|
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
|
288
385
|
})
|
|
289
386
|
if (!response.ok) throw new Error(`provider list failed (${response.status})`)
|
package/lib/scheduled.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { db, ensureMailSchema, recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
3
|
+
import { sendMail, type SendPayload } from '@/lib/mail-provider'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A message written now and carried later. Everything the send needs is prepared before it
|
|
7
|
+
* is parked, so dispatch is only ever "hand this to the provider" — the tracking pixel, the
|
|
8
|
+
* threading headers and the recipient list cannot drift between writing and sending.
|
|
9
|
+
*/
|
|
10
|
+
export type ScheduledSend = {
|
|
11
|
+
payload: SendPayload
|
|
12
|
+
owner: string
|
|
13
|
+
pixelId: string | null
|
|
14
|
+
subject: string
|
|
15
|
+
from: string
|
|
16
|
+
recipients: string[]
|
|
17
|
+
cc: string[]
|
|
18
|
+
bcc: string[]
|
|
19
|
+
replyTo: string[]
|
|
20
|
+
inReplyTo: string | null
|
|
21
|
+
storedAttachments: Array<{ filename: string; key: string }>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ScheduledRow = {
|
|
25
|
+
id: string
|
|
26
|
+
owner: string
|
|
27
|
+
sendAfter: string
|
|
28
|
+
status: string
|
|
29
|
+
subject: string
|
|
30
|
+
to: string[]
|
|
31
|
+
createdAt: string
|
|
32
|
+
lastError: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parse = (value: unknown): ScheduledSend | null => {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(String(value)) as ScheduledSend
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function scheduleSend(owner: string, sendAfter: string, send: ScheduledSend): Promise<string> {
|
|
44
|
+
await ensureMailSchema()
|
|
45
|
+
const id = randomUUID()
|
|
46
|
+
await db()`
|
|
47
|
+
INSERT INTO mail_scheduled (id, owner, send_after, status, payload, created_at)
|
|
48
|
+
VALUES (${id}, ${owner.toLowerCase()}, ${sendAfter}, 'pending', ${JSON.stringify(send)}, ${new Date().toISOString()})`
|
|
49
|
+
return id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function listScheduled(owner: string | null): Promise<ScheduledRow[]> {
|
|
53
|
+
await ensureMailSchema()
|
|
54
|
+
const sql = db()
|
|
55
|
+
const rows = owner
|
|
56
|
+
? await sql`SELECT * FROM mail_scheduled WHERE status = 'pending' AND lower(owner) = ${owner.toLowerCase()} ORDER BY send_after`
|
|
57
|
+
: await sql`SELECT * FROM mail_scheduled WHERE status = 'pending' ORDER BY send_after`
|
|
58
|
+
return rows.map(row => {
|
|
59
|
+
const send = parse(row.payload)
|
|
60
|
+
return {
|
|
61
|
+
id: String(row.id),
|
|
62
|
+
owner: String(row.owner),
|
|
63
|
+
sendAfter: String(row.send_after),
|
|
64
|
+
status: String(row.status),
|
|
65
|
+
subject: send?.subject ?? '(no subject)',
|
|
66
|
+
to: send?.recipients ?? [],
|
|
67
|
+
createdAt: String(row.created_at),
|
|
68
|
+
lastError: row.last_error == null ? null : String(row.last_error),
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Cancelling is only ever the owner's to do, and only while it is still waiting. */
|
|
74
|
+
export async function cancelScheduled(id: string, owner: string): Promise<boolean> {
|
|
75
|
+
await ensureMailSchema()
|
|
76
|
+
const rows = await db()`
|
|
77
|
+
UPDATE mail_scheduled SET status = 'cancelled'
|
|
78
|
+
WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()} AND status = 'pending'
|
|
79
|
+
RETURNING id`
|
|
80
|
+
return rows.length > 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The bookkeeping an immediate send does, so a delayed one lands in the same places. */
|
|
84
|
+
export async function recordSend(send: ScheduledSend, id: string | null): Promise<void> {
|
|
85
|
+
await Promise.all([
|
|
86
|
+
...[...send.recipients, ...send.cc, ...send.bcc].map(address => recordContact(address, null).catch(() => {})),
|
|
87
|
+
send.pixelId && id
|
|
88
|
+
? recordPixel(send.pixelId, id, send.recipients[0] ?? '', send.subject).catch(() => {})
|
|
89
|
+
: Promise.resolve(),
|
|
90
|
+
id ? recordSentMeta(id, send.owner, false, send.inReplyTo).catch(() => {}) : Promise.resolve(),
|
|
91
|
+
id
|
|
92
|
+
? recordSentMessage({
|
|
93
|
+
id,
|
|
94
|
+
from: send.from,
|
|
95
|
+
to: send.recipients,
|
|
96
|
+
cc: send.cc,
|
|
97
|
+
bcc: send.bcc,
|
|
98
|
+
replyTo: send.replyTo,
|
|
99
|
+
subject: send.subject,
|
|
100
|
+
html: send.payload.html ?? null,
|
|
101
|
+
text: send.payload.text ?? null,
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
attachments: send.storedAttachments,
|
|
104
|
+
}).catch(() => {})
|
|
105
|
+
: Promise.resolve(),
|
|
106
|
+
])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Moving the time is only the owner's to do, and only while it is still waiting. */
|
|
110
|
+
export async function rescheduleSend(id: string, owner: string, sendAfter: string): Promise<boolean> {
|
|
111
|
+
await ensureMailSchema()
|
|
112
|
+
const rows = await db()`
|
|
113
|
+
UPDATE mail_scheduled SET send_after = ${sendAfter}
|
|
114
|
+
WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()} AND status = 'pending'
|
|
115
|
+
RETURNING id`
|
|
116
|
+
return rows.length > 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MAX_ATTEMPTS = 5
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hands over every message whose time has come. Each row is claimed with a conditional
|
|
123
|
+
* update before it is sent, so two runners overlapping cannot send the same mail twice.
|
|
124
|
+
*/
|
|
125
|
+
export async function dispatchDue(limit = 25): Promise<{ due: number; sent: number; failed: number }> {
|
|
126
|
+
await ensureMailSchema()
|
|
127
|
+
const sql = db()
|
|
128
|
+
const now = new Date().toISOString()
|
|
129
|
+
const due = await sql`
|
|
130
|
+
SELECT id, payload, attempts FROM mail_scheduled
|
|
131
|
+
WHERE status = 'pending' AND send_after <= ${now}
|
|
132
|
+
ORDER BY send_after LIMIT ${limit}`
|
|
133
|
+
|
|
134
|
+
let sent = 0
|
|
135
|
+
let failed = 0
|
|
136
|
+
for (const row of due) {
|
|
137
|
+
const id = String(row.id)
|
|
138
|
+
const claimed = await sql`
|
|
139
|
+
UPDATE mail_scheduled SET status = 'sending' WHERE id = ${id} AND status = 'pending' RETURNING id`
|
|
140
|
+
if (!claimed.length) continue
|
|
141
|
+
|
|
142
|
+
const send = parse(row.payload)
|
|
143
|
+
if (!send) {
|
|
144
|
+
await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
|
|
145
|
+
failed += 1
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
151
|
+
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
152
|
+
const result = await sendMail(payload)
|
|
153
|
+
await recordSend(send, result?.id ?? null)
|
|
154
|
+
await sql`
|
|
155
|
+
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
|
156
|
+
sent += 1
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const attempts = Number(row.attempts ?? 0) + 1
|
|
159
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
160
|
+
await sql`
|
|
161
|
+
UPDATE mail_scheduled
|
|
162
|
+
SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
|
|
163
|
+
WHERE id = ${id}`
|
|
164
|
+
failed += 1
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { due: due.length, sent, failed }
|
|
168
|
+
}
|
package/lib/ses-send.ts
CHANGED
|
@@ -7,14 +7,26 @@ 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
|
-
|
|
10
|
+
/**
|
|
11
|
+
* `blindCopies` are envelope recipients only. A Bcc header inside the MIME would be handed
|
|
12
|
+
* to everyone the message reaches, so the address is named to SES instead of written down
|
|
13
|
+
* where the recipient can read it.
|
|
14
|
+
*/
|
|
15
|
+
export async function sesSendRaw(
|
|
16
|
+
raw: string,
|
|
17
|
+
region = process.env.AWS_SES_REGION ?? 'eu-north-1',
|
|
18
|
+
blindCopies: string[] = [],
|
|
19
|
+
): Promise<string | null> {
|
|
11
20
|
const id = process.env.SES_ACCESS_KEY_ID
|
|
12
21
|
const secret = process.env.SES_SECRET_ACCESS_KEY
|
|
13
22
|
if (!id || !secret) throw new Error('SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY must be configured')
|
|
14
23
|
|
|
15
24
|
const host = `email.${region}.amazonaws.com`
|
|
16
25
|
const path = '/v2/email/outbound-emails'
|
|
17
|
-
const body = JSON.stringify({
|
|
26
|
+
const body = JSON.stringify({
|
|
27
|
+
Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } },
|
|
28
|
+
...(blindCopies.length ? { Destination: { BccAddresses: blindCopies } } : {}),
|
|
29
|
+
})
|
|
18
30
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
19
31
|
const stamp = amzDate.slice(0, 8)
|
|
20
32
|
const payloadHash = sha256(body)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
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",
|
|
Binary file
|