@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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,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
|
+
}
|