@novacraft-engineering/mailbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,506 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { createHmac, timingSafeEqual } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { sendPush } from '@/lib/push'
5
+ import { FORWARD_RECIPIENTS, MAIL_DOMAIN, mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
6
+ import { ADDRESS_ALIASES, searchInbox, appendEvent, appendInbound, recordContact, recordSentMeta, getAccountByAddress, setInboundFlags, setInboundLabels, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
7
+ import { isBrevoInbound, normalizeBrevoInbound, sendMail } from '@/lib/mail-provider'
8
+
9
+ // The address we send from, and the inbox that owns mail addressed to nobody specific.
10
+ const MAIL_FROM = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
11
+ const SHARED_ADDRESS = MAIL_FROM.toLowerCase()
12
+
13
+ /** Attribute an inbound email to the accessor it was delivered to, else the shared inbox. */
14
+ async function attributeOwner(recipients: string[]): Promise<string> {
15
+ for (const raw of recipients) {
16
+ const match = raw.match(/<([^>]+)>/)
17
+ const addr = (match ? match[1] : raw).trim().toLowerCase()
18
+ if (!addr.includes('@')) continue
19
+ const account = await getAccountByAddress(addr)
20
+ if (account?.address) {
21
+ const owner = account.address.toLowerCase()
22
+ return ADDRESS_ALIASES[owner] ?? owner
23
+ }
24
+ }
25
+ return SHARED_ADDRESS
26
+ }
27
+
28
+ function parseSender(raw: string): { email: string; name: string | null } {
29
+ const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
30
+ if (match) return { name: match[1].replace(/^"|"$/g, '') || null, email: match[2].trim() }
31
+ return { name: null, email: raw.trim() }
32
+ }
33
+
34
+ export const runtime = 'nodejs'
35
+
36
+ function snippet(html: string | null, text: string | null): string {
37
+ const source = text?.trim() || (html ?? '').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ')
38
+ return source.replace(/\s+/g, ' ').trim().slice(0, 240)
39
+ }
40
+
41
+ function escapeHtml(src: string): string {
42
+ return src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
43
+ }
44
+
45
+ type ReceivedEmail = {
46
+ html: string | null
47
+ text: string | null
48
+ from: string
49
+ to: string[]
50
+ cc: string[]
51
+ bcc: string[]
52
+ replyTo: string[]
53
+ subject: string
54
+ headers: Record<string, unknown>
55
+ createdAt: string
56
+ attachments: Array<{ filename: string; contentType?: string }>
57
+ }
58
+
59
+ // The email.received webhook payload carries only metadata (from/to/subject) — the body
60
+ // lives on Resend and must be pulled from the receiving endpoint, or the inbox stores blanks.
61
+ async function fetchReceivedEmail(emailId: string, apiKey: string): Promise<ReceivedEmail | null> {
62
+ try {
63
+ const response = await fetch(`https://api.resend.com/emails/receiving/${emailId}`, {
64
+ headers: { Authorization: `Bearer ${apiKey}` },
65
+ })
66
+ if (!response.ok) return null
67
+ const data = (await response.json()) as Record<string, unknown>
68
+ const rawAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
69
+ return {
70
+ html: data.html ? String(data.html) : null,
71
+ text: data.text ? String(data.text) : null,
72
+ from: String(data.from ?? ''),
73
+ to: Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')],
74
+ cc: Array.isArray(data.cc) ? (data.cc as string[]) : [],
75
+ bcc: Array.isArray(data.bcc) ? (data.bcc as string[]) : [],
76
+ replyTo: Array.isArray(data.reply_to) ? (data.reply_to as string[]) : [],
77
+ subject: String(data.subject ?? '(no subject)'),
78
+ headers: data.headers && typeof data.headers === 'object' ? (data.headers as Record<string, unknown>) : {},
79
+ createdAt: String(data.created_at ?? new Date().toISOString()),
80
+ attachments: rawAttachments
81
+ .map(entry => ({
82
+ filename: String(entry.filename ?? entry.name ?? 'attachment'),
83
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
84
+ }))
85
+ .filter(entry => entry.filename),
86
+ }
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ type SendAttachment = { filename: string; content: string; contentType?: string; contentId?: string; size?: number }
93
+
94
+ /**
95
+ * Copy attachment bytes onto our own storage as the message arrives. Provider download URLs
96
+ * expire and disappear with the account, so a stored filename alone would outlive the file.
97
+ */
98
+ async function rehostAttachments(
99
+ emailId: string,
100
+ files: SendAttachment[],
101
+ ): Promise<Array<{ filename: string; contentType?: string; key?: string; size?: number; contentId?: string }>> {
102
+ if (!files.length) return []
103
+ const { putObject } = await import('@/lib/r2')
104
+ return Promise.all(
105
+ files.map(async (file, index) => {
106
+ if (!file.content) {
107
+ return { filename: file.filename, contentType: file.contentType, size: file.size, contentId: file.contentId }
108
+ }
109
+ const bytes = Buffer.from(file.content, 'base64')
110
+ // contentId is what separates an embedded signature image from a real attachment.
111
+ const meta = { filename: file.filename, contentType: file.contentType, size: bytes.length, contentId: file.contentId }
112
+ const safeName = (file.filename || 'attachment').replace(/[^\w.\- ]+/g, '_').slice(-120)
113
+ const key = `attachments/${emailId}/${index}-${safeName}`
114
+ // Written to our own bucket and served back through this domain. The provider's
115
+ // own link expires, dies with the account, and points at a host outside our
116
+ // control; the bucket URL would be a third host again, so neither is handed out.
117
+ return (await putObject(key, bytes, file.contentType)) ? { ...meta, key } : meta
118
+ }),
119
+ )
120
+ }
121
+
122
+ async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<SendAttachment[]> {
123
+ try {
124
+ const response = await fetch(`https://api.resend.com/emails/receiving/${emailId}/attachments`, {
125
+ headers: { Authorization: `Bearer ${apiKey}` },
126
+ })
127
+ if (!response.ok) return []
128
+ const data = (await response.json()) as { data?: Array<Record<string, unknown>> }
129
+ const list = Array.isArray(data.data) ? data.data : []
130
+ const out: SendAttachment[] = []
131
+ for (const entry of list) {
132
+ const url = entry.download_url ? String(entry.download_url) : ''
133
+ const file: SendAttachment = {
134
+ filename: String(entry.filename ?? 'attachment'),
135
+ content: '',
136
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
137
+ contentId: entry.content_id ? String(entry.content_id).replace(/^<|>$/g, '') : undefined,
138
+ size: Number(entry.size ?? 0) || undefined,
139
+ }
140
+ try {
141
+ const binary = url ? await fetch(url) : null
142
+ if (binary?.ok) file.content = Buffer.from(await binary.arrayBuffer()).toString('base64')
143
+ } catch (err) {
144
+ console.warn(`[mail] attachment pull failed for ${emailId}/${file.filename}:`, err)
145
+ }
146
+ out.push(file)
147
+ }
148
+ return out
149
+ } catch {
150
+ return []
151
+ }
152
+ }
153
+
154
+ // Forward the whole received email — full body + attachments — to the mail accounts.
155
+ // Forward the inbound copy ONLY to the account that owns the mail (its login email),
156
+ // never to every accessor — a member must not receive mail that isn't theirs.
157
+ async function forwardToAccounts(
158
+ emailId: string,
159
+ inbound: ReceivedEmail,
160
+ ownerAddress: string | null,
161
+ files?: SendAttachment[],
162
+ ): Promise<void> {
163
+ const owningAccount = ownerAddress ? await getAccountByAddress(ownerAddress) : null
164
+ const recipient = owningAccount?.email ?? FORWARD_RECIPIENTS[0] // fallback: the admin
165
+ if (!recipient) return
166
+ // Forwarding exists to put a copy somewhere the recipient already reads. Once this
167
+ // domain's MX points here, an address on it is somewhere we read — so forwarding to
168
+ // one would deliver back into this handler and forward again, without end.
169
+ if (recipient.trim().toLowerCase().endsWith(`@${MAIL_DOMAIN}`)) return
170
+ const from = MAIL_FROM
171
+ // Attachment bytes only come from Resend's receiving API; under another provider the
172
+ // forward still goes out, just without re-attaching the files.
173
+ const resendKey = process.env.RESEND_API_KEY
174
+ const attachments = (files ?? (resendKey ? await fetchAttachmentBytes(emailId, resendKey) : [])).filter(file => file.content)
175
+ const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${BRAND.colors.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br><strong>From:</strong> ${escapeHtml(inbound.from)}<br><strong>Subject:</strong> ${escapeHtml(inbound.subject)}</div>`
176
+ try {
177
+ const data = await sendMail({
178
+ from: from.replace(/^.*<|>$/g, ''),
179
+ fromName: `${BRAND.name} Mail`,
180
+ to: [recipient],
181
+ replyTo: inbound.from || undefined,
182
+ subject: inbound.subject.startsWith('Fwd:') ? inbound.subject : `Fwd: ${inbound.subject}`,
183
+ text: inbound.text?.trim() || snippet(inbound.html, inbound.text) || ' ',
184
+ ...(inbound.html ? { html: `${header}${inbound.html}` } : {}),
185
+ ...(attachments.length ? { attachments } : {}),
186
+ })
187
+ // Tag as automated so this "Fwd:" copy never shows up in the Sent folder.
188
+ if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
189
+ } catch (err) {
190
+ console.warn('[mail] inbound forward failed:', err)
191
+ }
192
+ }
193
+
194
+ export async function GET(req: Request) {
195
+ const guard = await mailAuthGuard(req)
196
+ if (guard) return guard
197
+ const account = await resolveAccount(req)
198
+ // Everyone sees their own mailbox and only their own. Administering accounts is a
199
+ // separate power from reading other people's mail, so an admin is scoped here too.
200
+ const ownerFilter: string = account.address ?? ' no-address'
201
+ // A query or an explicit page means the caller wants the searchable, paged path.
202
+ // Without either, fall back to readInbox so existing callers are unaffected.
203
+ const params = new URL(req.url).searchParams
204
+ const text = params.get('q')?.trim()
205
+ const limitRaw = params.get('limit')
206
+ const offset = Number(params.get('offset') ?? 0) || 0
207
+
208
+ const cursor = params.get('cursor')
209
+
210
+ const threadId = params.get('thread') ?? undefined
211
+ if (text || limitRaw || offset || cursor || threadId) {
212
+ const folderParam = params.get('folder')
213
+ const folder = (['inbox', 'archive', 'trash', 'starred'] as const).find(f => f === folderParam)
214
+ const { rows, total, nextCursor } = await searchInbox({
215
+ text,
216
+ owner: ownerFilter,
217
+ folder,
218
+ limit: Number(limitRaw ?? 50) || 50,
219
+ offset,
220
+ cursor,
221
+ // Counting scans the whole match. Unfiltered, the sidebar already has this number
222
+ // from countFolders, so only a search — whose match nothing else knows — pays for it.
223
+ threadId,
224
+ withTotal: !cursor && Boolean(text),
225
+ unread: params.get('unread') === '1' ? true : undefined,
226
+ starred: params.get('starred') === '1' ? true : undefined,
227
+ hasAttachment: params.get('attachment') === '1' || undefined,
228
+ label: params.get('label') ?? undefined,
229
+ from: params.get('from') ?? undefined,
230
+ to: params.get('to') ?? undefined,
231
+ })
232
+ return NextResponse.json({ ok: true, emails: rows, total, offset, nextCursor })
233
+ }
234
+
235
+ const emails = await readInbox(ownerFilter ? { owner: ownerFilter } : undefined)
236
+ return NextResponse.json({ ok: true, emails })
237
+ }
238
+
239
+ export async function PATCH(req: Request) {
240
+ const guard = await mailAuthGuard(req)
241
+ if (guard) return guard
242
+ let body: { id?: string; ids?: string[]; labels?: string[]; owner?: string } & InboundFlags
243
+ try {
244
+ body = await req.json()
245
+ } catch {
246
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
247
+ }
248
+ const ids = body.ids ?? (body.id ? [body.id] : [])
249
+ if (!ids.length) return NextResponse.json({ ok: false, error: 'id or ids is required' }, { status: 400 })
250
+ // Reassigning inbound mail to a mailbox is admin-only.
251
+ if (body.owner !== undefined) {
252
+ const account = await resolveAccount(req)
253
+ if (account.role !== 'admin') {
254
+ return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
255
+ }
256
+ await Promise.all(ids.map(id => setInboxOwner(id, body.owner!)))
257
+ return NextResponse.json({ ok: true })
258
+ }
259
+ if (Array.isArray(body.labels)) {
260
+ const labels = body.labels.map(String)
261
+ await Promise.all(ids.map(id => setInboundLabels(id, labels)))
262
+ return NextResponse.json({ ok: true })
263
+ }
264
+ const flags: InboundFlags = {}
265
+ if (body.read !== undefined) flags.read = body.read
266
+ if (body.starred !== undefined) flags.starred = body.starred
267
+ if (body.archived !== undefined) flags.archived = body.archived
268
+ if (body.trashed !== undefined) flags.trashed = body.trashed
269
+ if (!Object.keys(flags).length) flags.read = true
270
+ await Promise.all(ids.map(id => setInboundFlags(id, flags)))
271
+ return NextResponse.json({ ok: true })
272
+ }
273
+
274
+ // Svix signature scheme used by Resend webhooks: HMAC-SHA256 over `${id}.${timestamp}.${payload}`
275
+ // with the base64-decoded secret after the `whsec_` prefix.
276
+ const SIGNATURE_MAX_AGE_MS = 48 * 60 * 60 * 1000
277
+
278
+ function verifyWebhookSignature(req: Request, payload: string): boolean {
279
+ const secret = process.env.RESEND_WEBHOOK_SECRET
280
+ // No secret configured: allow only local development, never unsigned prod posts.
281
+ if (!secret) return isLocalOrigin(req)
282
+ const svixId = req.headers.get('svix-id') ?? ''
283
+ const svixTimestamp = req.headers.get('svix-timestamp') ?? ''
284
+ const svixSignature = req.headers.get('svix-signature') ?? ''
285
+ if (!svixId || !svixTimestamp || !svixSignature) return false
286
+
287
+ // Wide on purpose. Duplicate suppression is the delivery-id claim, not this window,
288
+ // because a provider's retry schedule runs for hours and a tight window here would
289
+ // throw away the redelivery of a message we never managed to store. This only stops
290
+ // a captured request being replayed indefinitely.
291
+ const sentAt = Number(svixTimestamp) * 1000
292
+ if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > SIGNATURE_MAX_AGE_MS) return false
293
+
294
+ const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
295
+ const expected = createHmac('sha256', key)
296
+ .update(`${svixId}.${svixTimestamp}.${payload}`)
297
+ .digest('base64')
298
+
299
+ return svixSignature.split(' ').some(part => {
300
+ const candidate = part.split(',')[1] ?? ''
301
+ try {
302
+ return (
303
+ candidate.length === expected.length &&
304
+ timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))
305
+ )
306
+ } catch {
307
+ return false
308
+ }
309
+ })
310
+ }
311
+
312
+ /**
313
+ * Brevo inbound parsing has no signature scheme, so the webhook URL carries a shared
314
+ * secret: .../api/mail/inbox?secret=… — treated as a bearer token.
315
+ */
316
+ function verifyBrevoSecret(req: Request): boolean {
317
+ const expected = process.env.BREVO_WEBHOOK_SECRET
318
+ if (!expected) return isLocalOrigin(req)
319
+ const provided = new URL(req.url).searchParams.get('secret') ?? ''
320
+ if (provided.length !== expected.length) return false
321
+ try {
322
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
323
+ } catch {
324
+ return false
325
+ }
326
+ }
327
+
328
+ export async function POST(req: Request) {
329
+ const payload = await req.text()
330
+
331
+ let parsed: Record<string, unknown>
332
+ try {
333
+ parsed = JSON.parse(payload)
334
+ } catch {
335
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
336
+ }
337
+
338
+ // ── Brevo inbound parsing ──────────────────────────────────────
339
+ if (isBrevoInbound(parsed)) {
340
+ if (!verifyBrevoSecret(req)) {
341
+ return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 401 })
342
+ }
343
+ const items = Array.isArray((parsed as { items?: unknown[] }).items)
344
+ ? ((parsed as { items: Record<string, unknown>[] }).items)
345
+ : [parsed]
346
+ let stored = 0
347
+ for (const item of items) {
348
+ let deliveryId = ''
349
+ try {
350
+ const message = normalizeBrevoInbound(item)
351
+ // Brevo carries no delivery id, so the message id stands in: a replayed batch
352
+ // must not forward a second copy of mail already handled.
353
+ deliveryId = `brevo:${message.id}`
354
+ if (!(await claimWebhookEvent(deliveryId))) continue
355
+ const owner = await attributeOwner([...message.to, ...message.cc, ...message.bcc])
356
+ await appendInbound({ ...message, read: false, owner })
357
+ const sender = parseSender(message.from)
358
+ await recordContact(sender.email, sender.name).catch(() => {})
359
+ await forwardToAccounts(
360
+ message.id,
361
+ {
362
+ html: message.html,
363
+ text: message.text,
364
+ from: message.from,
365
+ to: message.to,
366
+ cc: message.cc,
367
+ bcc: message.bcc,
368
+ replyTo: message.replyTo,
369
+ subject: message.subject,
370
+ headers: message.headers,
371
+ createdAt: message.receivedAt,
372
+ attachments: message.attachments,
373
+ },
374
+ owner,
375
+ )
376
+ await completeWebhookEvent(deliveryId)
377
+ stored++
378
+ } catch (err) {
379
+ await releaseWebhookEvent(deliveryId)
380
+ console.warn('[mail] brevo inbound item failed:', err)
381
+ }
382
+ }
383
+ return NextResponse.json({ ok: true, stored })
384
+ }
385
+
386
+ // ── Resend webhook ─────────────────────────────────────────────
387
+ if (!verifyWebhookSignature(req, payload)) {
388
+ return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 401 })
389
+ }
390
+ const event = parsed as { type?: string; data?: Record<string, unknown> }
391
+
392
+ const type = event.type ?? ''
393
+ const data = event.data
394
+ if (!type || !data) {
395
+ return NextResponse.json({ ok: true, ignored: true })
396
+ }
397
+
398
+ if (type === 'email.received') {
399
+ const emailId = String(data.email_id ?? data.id ?? `in_${Date.now()}`)
400
+ // Keyed on the delivery id, which a retry reuses, so a redelivery of something we
401
+ // already finished is a no-op rather than a second forwarded copy.
402
+ const deliveryId = req.headers.get('svix-id') || `received:${emailId}`
403
+ if (!(await claimWebhookEvent(deliveryId))) {
404
+ return NextResponse.json({ ok: true, duplicate: true })
405
+ }
406
+ try {
407
+ const apiKey = process.env.RESEND_API_KEY
408
+ // The webhook payload has no body — pull the full email (html/text/attachments) from Resend.
409
+ const full = apiKey ? await fetchReceivedEmail(emailId, apiKey) : null
410
+
411
+ const payloadAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
412
+ const inbound = {
413
+ id: emailId,
414
+ from: full?.from || String(data.from ?? ''),
415
+ to: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
416
+ cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
417
+ bcc: full?.bcc ?? (Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
418
+ replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
419
+ subject: full?.subject || String(data.subject ?? '(no subject)'),
420
+ html: full?.html ?? (data.html ? String(data.html) : null),
421
+ text: full?.text ?? (data.text ? String(data.text) : null),
422
+ headers: full?.headers ?? {},
423
+ receivedAt: full?.createdAt || String(data.created_at ?? new Date().toISOString()),
424
+ read: false,
425
+ attachments:
426
+ full?.attachments ??
427
+ payloadAttachments
428
+ .map(entry => ({
429
+ filename: String(entry.filename ?? entry.name ?? 'attachment'),
430
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
431
+ }))
432
+ .filter(entry => entry.filename),
433
+ // Attribute (and therefore forward) to the accessor actually addressed — to, cc, OR bcc.
434
+ // Anything not matching a member's personal address stays in the shared (admin) inbox,
435
+ // so a member never receives mail they aren't a party to.
436
+ owner: await attributeOwner([
437
+ ...(Array.isArray(full?.to) ? full!.to : []),
438
+ ...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
439
+ ...(full?.cc ?? []),
440
+ ...(full?.bcc ?? []),
441
+ ]),
442
+ }
443
+ // Pull the bytes once: they become our stored copy and the forward's attachments.
444
+ const files = apiKey ? await fetchAttachmentBytes(emailId, apiKey) : []
445
+ if (files.length) inbound.attachments = await rehostAttachments(emailId, files)
446
+ const forwardable = files.filter(file => file.content)
447
+
448
+ await appendInbound(inbound)
449
+ const sender = parseSender(inbound.from)
450
+ await recordContact(sender.email, sender.name)
451
+ await sendPush(inbound.owner, {
452
+ title: sender.name || sender.email || 'New mail',
453
+ body: inbound.subject,
454
+ tag: emailId,
455
+ }).catch(() => {})
456
+ await forwardToAccounts(
457
+ emailId,
458
+ full ?? {
459
+ html: inbound.html,
460
+ text: inbound.text,
461
+ from: inbound.from,
462
+ to: inbound.to,
463
+ cc: inbound.cc,
464
+ bcc: inbound.bcc,
465
+ replyTo: inbound.replyTo,
466
+ subject: inbound.subject,
467
+ headers: inbound.headers,
468
+ createdAt: inbound.receivedAt,
469
+ attachments: inbound.attachments,
470
+ },
471
+ inbound.owner,
472
+ forwardable,
473
+ )
474
+ } catch (err) {
475
+ // Give the id back so the provider's retry is allowed to redo the work.
476
+ await releaseWebhookEvent(deliveryId)
477
+ throw err
478
+ }
479
+ await completeWebhookEvent(deliveryId)
480
+ void pruneWebhookEvents().catch(() => {})
481
+ return NextResponse.json({ ok: true })
482
+ }
483
+
484
+ const meta: Record<string, string> = {}
485
+ if (data.subject) meta.subject = String(data.subject)
486
+ if (data.to) meta.to = Array.isArray(data.to) ? (data.to as string[]).join(', ') : String(data.to)
487
+ const click = data.click as Record<string, unknown> | undefined
488
+ if (click?.link) meta.link = String(click.link)
489
+ const bounce = data.bounce as Record<string, unknown> | undefined
490
+ if (bounce?.type) meta.bounceType = String(bounce.type)
491
+ if (bounce?.message) meta.bounceMessage = String(bounce.message)
492
+ const failed = data.failed as Record<string, unknown> | undefined
493
+ if (failed?.reason) meta.failReason = String(failed.reason)
494
+ if (type.startsWith('contact.') || type.startsWith('domain.')) {
495
+ if (data.email) meta.contact = String(data.email)
496
+ if (data.name) meta.name = String(data.name)
497
+ }
498
+
499
+ await appendEvent({
500
+ emailId: String(data.email_id ?? ''),
501
+ type,
502
+ at: String(data.created_at ?? new Date().toISOString()),
503
+ meta: Object.keys(meta).length ? meta : undefined,
504
+ })
505
+ return NextResponse.json({ ok: true })
506
+ }
@@ -0,0 +1,95 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { inspectUrl, mergeVerdict, normalizeUrl, type LinkVerdict } from '@/lib/link-safety'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ const SAFE_BROWSING = 'https://safebrowsing.googleapis.com/v4/threatMatches:find'
8
+
9
+ // Verdicts are stable for a while and emails get re-opened constantly — cache per instance.
10
+ const cache = new Map<string, { verdict: LinkVerdict; reasons: string[]; at: number }>()
11
+ const TTL_MS = 6 * 60 * 60 * 1000
12
+
13
+ const THREAT_LABEL: Record<string, string> = {
14
+ MALWARE: 'Known malware host (Google Safe Browsing)',
15
+ SOCIAL_ENGINEERING: 'Known phishing site (Google Safe Browsing)',
16
+ UNWANTED_SOFTWARE: 'Distributes unwanted software (Google Safe Browsing)',
17
+ POTENTIALLY_HARMFUL_APPLICATION: 'Potentially harmful application (Google Safe Browsing)',
18
+ }
19
+
20
+ async function lookupRemote(urls: string[]): Promise<Map<string, string[]>> {
21
+ const key = process.env.SAFE_BROWSING_API_KEY
22
+ const hits = new Map<string, string[]>()
23
+ if (!key || !urls.length) return hits
24
+ try {
25
+ const response = await fetch(`${SAFE_BROWSING}?key=${key}`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({
29
+ client: { clientId: 'mailbox', clientVersion: '1.0.0' },
30
+ threatInfo: {
31
+ threatTypes: ['MALWARE', 'SOCIAL_ENGINEERING', 'UNWANTED_SOFTWARE', 'POTENTIALLY_HARMFUL_APPLICATION'],
32
+ platformTypes: ['ANY_PLATFORM'],
33
+ threatEntryTypes: ['URL'],
34
+ threatEntries: urls.map(url => ({ url })),
35
+ },
36
+ }),
37
+ })
38
+ if (!response.ok) return hits
39
+ const data = (await response.json()) as { matches?: Array<{ threat?: { url?: string }; threatType?: string }> }
40
+ for (const match of data.matches ?? []) {
41
+ const url = match.threat?.url
42
+ if (!url) continue
43
+ const label = THREAT_LABEL[match.threatType ?? ''] ?? 'Flagged by Google Safe Browsing'
44
+ hits.set(url, [...(hits.get(url) ?? []), label])
45
+ }
46
+ } catch {
47
+ // Network/API failure must not block reading mail — fall back to heuristics.
48
+ }
49
+ return hits
50
+ }
51
+
52
+ export async function POST(req: Request) {
53
+ const guard = await mailAuthGuard(req)
54
+ if (guard) return guard
55
+
56
+ let body: { links?: Array<{ url: string; text?: string }> }
57
+ try {
58
+ body = await req.json()
59
+ } catch {
60
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
61
+ }
62
+
63
+ const links = (body.links ?? []).filter(entry => entry?.url).slice(0, 100)
64
+ if (!links.length) return NextResponse.json({ ok: true, results: {}, remoteEnabled: Boolean(process.env.SAFE_BROWSING_API_KEY) })
65
+
66
+ const now = Date.now()
67
+ const results: Record<string, { verdict: LinkVerdict; reasons: string[] }> = {}
68
+ const needRemote: string[] = []
69
+
70
+ for (const link of links) {
71
+ const url = normalizeUrl(link.url.trim())
72
+ const local = inspectUrl(url, link.text)
73
+ const cached = cache.get(url)
74
+ if (cached && now - cached.at < TTL_MS) {
75
+ results[link.url] = { verdict: mergeVerdict(local.verdict, cached.verdict), reasons: [...new Set([...local.reasons, ...cached.reasons])] }
76
+ continue
77
+ }
78
+ results[link.url] = { verdict: local.verdict, reasons: local.reasons }
79
+ needRemote.push(url)
80
+ }
81
+
82
+ const remote = await lookupRemote(needRemote)
83
+ for (const link of links) {
84
+ const url = normalizeUrl(link.url.trim())
85
+ const threats = remote.get(url)
86
+ if (threats?.length) {
87
+ results[link.url] = { verdict: 'dangerous', reasons: [...new Set([...(results[link.url]?.reasons ?? []), ...threats])] }
88
+ cache.set(url, { verdict: 'dangerous', reasons: threats, at: now })
89
+ } else if (needRemote.includes(url)) {
90
+ cache.set(url, { verdict: 'safe', reasons: [], at: now })
91
+ }
92
+ }
93
+
94
+ return NextResponse.json({ ok: true, results, remoteEnabled: Boolean(process.env.SAFE_BROWSING_API_KEY) })
95
+ }
@@ -0,0 +1,38 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { currentFingerprint, verifyMailAuth } from '@/lib/dev-auth'
3
+ import { clientKey, rateLimit, clearRateLimit } from '@/lib/rate-limit'
4
+ import { attachSession, issueSession } from '@/lib/session'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /** Ten attempts per address per fifteen minutes: generous for a typo, useless for a list. */
10
+ const MAX_ATTEMPTS = 10
11
+ const WINDOW_MS = 15 * 60 * 1000
12
+
13
+ export async function POST(req: Request) {
14
+ const email = (req.headers.get('x-dev-email') ?? '').trim().toLowerCase()
15
+ const password = req.headers.get('x-dev-password') ?? ''
16
+
17
+ // Keyed on address as well as caller, so one shared office IP cannot lock
18
+ // everybody out, and a rotating-IP attacker still meets a per-account ceiling.
19
+ const key = `${clientKey(req, 'login')}:${email}`
20
+ const limited = rateLimit(key, MAX_ATTEMPTS, WINDOW_MS)
21
+ if (limited) return limited
22
+
23
+ if (!email || !password) {
24
+ return NextResponse.json({ ok: false, error: 'Enter your email and password.' }, { status: 400 })
25
+ }
26
+
27
+ const result = await verifyMailAuth(email, password)
28
+ if (!result.ok) {
29
+ // Deliberately the same message whether the address exists or not, so the
30
+ // response cannot be used to enumerate who has a mailbox here.
31
+ return NextResponse.json({ ok: false, error: 'Email or password is incorrect.' }, { status: 401 })
32
+ }
33
+
34
+ clearRateLimit(key)
35
+ const identity = result.email ?? email
36
+ const token = issueSession(identity, await currentFingerprint(identity))
37
+ return attachSession(NextResponse.json({ ok: true, email: identity, session: Boolean(token) }), token)
38
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { clearSession } from '@/lib/session'
3
+
4
+ export const runtime = 'nodejs'
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function POST() {
8
+ return clearSession(NextResponse.json({ ok: true }))
9
+ }
@@ -0,0 +1,26 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { backfillAttachments } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+ export const maxDuration = 300
7
+
8
+ function authorised(req: Request): boolean {
9
+ const secret = process.env.MAINTENANCE_TOKEN
10
+ if (!secret) return false
11
+ const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
12
+ if (token.length !== secret.length) return false
13
+ try {
14
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ export async function POST(req: Request) {
21
+ if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
22
+ const url = new URL(req.url)
23
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 25) || 25, 100)
24
+ const result = await backfillAttachments(limit, url.searchParams.get('count') === '1')
25
+ return NextResponse.json({ ok: true, ...result })
26
+ }