@novacraft-engineering/mailbox 0.1.0 → 0.2.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/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src=".github/banner.png" alt="mailbox by Novacraft" width="100%">
|
|
2
|
+
|
|
1
3
|
# Mailbox
|
|
2
4
|
|
|
3
5
|
A shared webmail app: one codebase, one deployment per mailbox. Inbox and
|
|
@@ -16,8 +18,11 @@ for it in that same request.** This is a standing rule for every agent and every
|
|
|
16
18
|
session. A granted deploy covers that one deploy only; it is not permission for
|
|
17
19
|
the next one.
|
|
18
20
|
|
|
19
|
-
Here, pushing *is* deploying.
|
|
20
|
-
|
|
21
|
+
Here, pushing *is* deploying — for one tenant. A GitHub webhook on this repository
|
|
22
|
+
points at the Coolify that serves production, so a push to `master` auto-deploys the
|
|
23
|
+
**Novacraft** mailbox. The second tenant is deliberately not wired: it shares this
|
|
24
|
+
repository and branch, but Coolify checks the webhook signature against each
|
|
25
|
+
application's own secret, so it is skipped and still needs a manual deploy from Coolify.
|
|
21
26
|
Finish the work, commit locally, leave it unpushed, and say plainly that it is
|
|
22
27
|
waiting.
|
|
23
28
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BRAND, ADDRESS_DOMAINS as BRAND_ADDRESS_DOMAINS } from '@/lib/brand'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { NextResponse } from 'next/server'
|
|
4
|
-
import {
|
|
4
|
+
import { sendMail } from '@/lib/mail-provider'
|
|
5
|
+
import { renderActionEmail } from '@/lib/emails'
|
|
5
6
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
6
7
|
import {
|
|
7
8
|
listAccounts,
|
|
@@ -102,29 +103,32 @@ export async function POST(req: Request) {
|
|
|
102
103
|
const token = randomBytes(32).toString('hex')
|
|
103
104
|
await createResetToken(email, token, Date.now() + INVITE_TTL_MS)
|
|
104
105
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
106
|
+
const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
|
|
107
|
+
const origin = process.env.MAIL_PUBLIC_URL?.replace(/\/$/, '') || new URL(req.url).origin
|
|
108
|
+
const inviteUrl = `${origin}/mail/reset?token=${token}&invite=1`
|
|
109
|
+
try {
|
|
110
|
+
const { id } = await sendMail({
|
|
111
|
+
from,
|
|
112
|
+
fromName: `${BRAND.name} Mail`,
|
|
113
|
+
to: [email],
|
|
114
|
+
subject: `You've been invited to ${BRAND.name} Mail`,
|
|
115
|
+
text: `${inviter.email} invited you to ${BRAND.name} Mail.\n\nSet your password to get started: ${inviteUrl}\n\nThis link expires in 7 days.`,
|
|
116
|
+
html: renderActionEmail({
|
|
117
|
+
eyebrow: `${BRAND.name} · Mail`,
|
|
118
|
+
accent: BRAND.colors.accent,
|
|
119
|
+
title: `You've been invited to ${BRAND.name} Mail`,
|
|
120
|
+
body: `${inviter.email} gave you access to ${BRAND.name} Mail${name ? `, ${name}` : ''}. Set a password to get started.`,
|
|
121
|
+
actionLabel: 'Set your password',
|
|
122
|
+
actionUrl: inviteUrl,
|
|
123
|
+
expiry: 'This invite link expires in 7 days.',
|
|
124
|
+
footer: `You are receiving this because ${inviter.email} invited you.`,
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
if (id) await recordSentMeta(id, null, true).catch(() => {})
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// The account row already exists; say the invite did not go out rather than imply it did.
|
|
130
|
+
console.error('[mail] invite email failed:', err)
|
|
131
|
+
return NextResponse.json({ ok: false, error: 'Account created, but the invite email could not be sent', email, address, role }, { status: 502 })
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
return NextResponse.json({ ok: true, email, address, role })
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
2
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
|
+
import { mayReadSent } from '@/lib/sent-access'
|
|
3
4
|
import { getSentAttachments } from '@/lib/mailbox'
|
|
4
5
|
import { getObject } from '@/lib/r2'
|
|
5
6
|
|
|
@@ -18,6 +19,7 @@ export async function GET(req: Request, context: { params: Promise<{ id: string
|
|
|
18
19
|
if (guard) return guard
|
|
19
20
|
|
|
20
21
|
const { id } = await context.params
|
|
22
|
+
if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
21
23
|
const params = new URL(req.url).searchParams
|
|
22
24
|
const index = Number(params.get('index') ?? 0)
|
|
23
25
|
const inline = params.get('inline') === '1'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
|
-
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { mayReadSent } from '@/lib/sent-access'
|
|
4
5
|
import { getSentAttachments } from '@/lib/mailbox'
|
|
5
6
|
import { getObject, putObject } from '@/lib/r2'
|
|
6
7
|
|
|
@@ -21,6 +22,7 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
|
|
|
21
22
|
if (guard) return guard
|
|
22
23
|
|
|
23
24
|
const { id } = await context.params
|
|
25
|
+
if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
24
26
|
const copied: Array<{ filename: string; size: number; contentType?: string; key: string }> = []
|
|
25
27
|
|
|
26
28
|
const mint = (filename: string) =>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { Resend } from 'resend'
|
|
3
|
-
import { mailAuthGuard } from '@/lib/dev-auth'
|
|
3
|
+
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { mayReadSent } from '@/lib/sent-access'
|
|
4
5
|
import { getSentAttachments, readSentMessage } from '@/lib/mailbox'
|
|
5
6
|
|
|
6
7
|
export const runtime = 'nodejs'
|
|
@@ -15,9 +16,19 @@ function getResend(): Resend | null {
|
|
|
15
16
|
* our own mail_sent row is the durable source, and the provider is asked only for the
|
|
16
17
|
* details it alone holds (attachment links, schedule, delivery state).
|
|
17
18
|
*/
|
|
19
|
+
/** Served through this domain rather than the provider's host, which does not resolve on some of the networks the office uses. */
|
|
20
|
+
function attachmentList(id: string, entries: Array<Record<string, unknown>>) {
|
|
21
|
+
return entries.map((entry, index) => ({
|
|
22
|
+
filename: String(entry.filename ?? 'attachment'),
|
|
23
|
+
size: Number(entry.size ?? 0),
|
|
24
|
+
downloadUrl: `/api/mail/emails/${encodeURIComponent(id)}/attachments/download?index=${index}`,
|
|
25
|
+
}))
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
async function fromArchive(id: string) {
|
|
19
29
|
const stored = await readSentMessage(id).catch(() => null)
|
|
20
30
|
if (!stored) return null
|
|
31
|
+
const files = await getSentAttachments(id).catch(() => [])
|
|
21
32
|
return NextResponse.json({
|
|
22
33
|
ok: true,
|
|
23
34
|
email: {
|
|
@@ -33,7 +44,7 @@ async function fromArchive(id: string) {
|
|
|
33
44
|
createdAt: stored.createdAt,
|
|
34
45
|
scheduledAt: null,
|
|
35
46
|
lastEvent: stored.lastEvent ?? '',
|
|
36
|
-
attachments:
|
|
47
|
+
attachments: attachmentList(id, files as Array<Record<string, unknown>>),
|
|
37
48
|
},
|
|
38
49
|
})
|
|
39
50
|
}
|
|
@@ -42,6 +53,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
42
53
|
const guard = await mailAuthGuard(req)
|
|
43
54
|
if (guard) return guard
|
|
44
55
|
const { id } = await params
|
|
56
|
+
if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
45
57
|
const resend = getResend()
|
|
46
58
|
|
|
47
59
|
if (id.startsWith('mbox-') || !resend) {
|
|
@@ -63,13 +75,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
63
75
|
: ((await resend.emails.attachments.list({ emailId: id }).catch(() => null))?.data as unknown as {
|
|
64
76
|
data?: Array<Record<string, unknown>>
|
|
65
77
|
})?.data ?? []
|
|
66
|
-
|
|
67
|
-
// on some of the networks the office uses.
|
|
68
|
-
const attachments = rawAttachments.map((entry, index) => ({
|
|
69
|
-
filename: String((entry as Record<string, unknown>).filename ?? 'attachment'),
|
|
70
|
-
size: Number((entry as Record<string, unknown>).size ?? 0),
|
|
71
|
-
downloadUrl: `/api/mail/emails/${encodeURIComponent(id)}/attachments/download?index=${index}`,
|
|
72
|
-
}))
|
|
78
|
+
const attachments = attachmentList(id, rawAttachments as Array<Record<string, unknown>>)
|
|
73
79
|
|
|
74
80
|
const item = data as unknown as Record<string, unknown>
|
|
75
81
|
return NextResponse.json({
|
|
@@ -99,6 +105,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|
|
99
105
|
if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
|
|
100
106
|
|
|
101
107
|
const { id } = await params
|
|
108
|
+
if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
102
109
|
let body: { scheduledAt?: string }
|
|
103
110
|
try {
|
|
104
111
|
body = await req.json()
|
|
@@ -121,6 +128,7 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ id: s
|
|
|
121
128
|
if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
|
|
122
129
|
|
|
123
130
|
const { id } = await params
|
|
131
|
+
if (!(await mayReadSent(await resolveAccount(req), id))) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
124
132
|
const { error } = await resend.emails.cancel(id)
|
|
125
133
|
if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
|
|
126
134
|
return NextResponse.json({ ok: true })
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
2
|
import { NextResponse } from 'next/server'
|
|
3
|
+
import { matchesQuery, parseQuery } from '@/app/mail/search'
|
|
3
4
|
import { FORWARD_RECIPIENTS, mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
4
5
|
import { readSentFlags, setSentFlags, readPixelOpens, readSentMeta, setSentMetaOwner, listAccounts, readSentArchive, type SentFlags } from '@/lib/mailbox'
|
|
5
6
|
|
|
@@ -17,7 +18,6 @@ const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
|
|
|
17
18
|
|
|
18
19
|
/** Heuristic for pre-existing automated sends that predate owner/auto tagging. */
|
|
19
20
|
function looksAutomated(email: SentEmail): boolean {
|
|
20
|
-
if (email.subject.startsWith('Fwd:')) return true
|
|
21
21
|
if (KNOWN_AUTO_SUBJECTS.has(email.subject)) return true
|
|
22
22
|
const internal = new Set(
|
|
23
23
|
[...FORWARD_RECIPIENTS, ...(process.env.RESEND_TO ?? '').split(',')]
|
|
@@ -35,10 +35,19 @@ export async function GET(req: Request) {
|
|
|
35
35
|
if (guard) return guard
|
|
36
36
|
|
|
37
37
|
const account = await resolveAccount(req)
|
|
38
|
+
const rawQuery = new URL(req.url).searchParams.get('q')?.trim() ?? ''
|
|
39
|
+
const parsedQuery = rawQuery ? parseQuery(rawQuery) : null
|
|
40
|
+
const query = parsedQuery && !parsedQuery.isEmpty ? parsedQuery : null
|
|
41
|
+
const SEARCH_LIMIT = 400
|
|
38
42
|
// Read our own archive rather than the provider's list: the history stays ours across
|
|
39
43
|
// a provider switch, and every send is written here at send time.
|
|
40
44
|
// Meta is looked up only for the archive rows being returned, so fetch those first.
|
|
41
|
-
const archive = await readSentArchive(
|
|
45
|
+
const archive = await readSentArchive({
|
|
46
|
+
ownerAddress: account.address ?? null,
|
|
47
|
+
sharedAddress: SHARED_ADDRESS,
|
|
48
|
+
query,
|
|
49
|
+
limit: query ? SEARCH_LIMIT : 500,
|
|
50
|
+
}).catch(() => [])
|
|
42
51
|
const [flags, opens, sentMeta, accounts] = await Promise.all([
|
|
43
52
|
readSentFlags().catch(() => ({})),
|
|
44
53
|
readPixelOpens().catch(() => ({})),
|
|
@@ -46,6 +55,8 @@ export async function GET(req: Request) {
|
|
|
46
55
|
listAccounts().catch(() => []),
|
|
47
56
|
])
|
|
48
57
|
|
|
58
|
+
const archiveById = new Map(archive.map(item => [item.id, item]))
|
|
59
|
+
const flagOf = flags as Record<string, { starred?: boolean; archived?: boolean; trashed?: boolean }>
|
|
49
60
|
const all: SentEmail[] = archive.map(item => ({
|
|
50
61
|
id: item.id,
|
|
51
62
|
from: item.from,
|
|
@@ -81,8 +92,28 @@ export async function GET(req: Request) {
|
|
|
81
92
|
return true
|
|
82
93
|
})
|
|
83
94
|
.map(email => ({ ...email, owner: sentOwner(email), inReplyTo: metaMap[email.id]?.inReplyTo ?? null }))
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
.filter(email =>
|
|
96
|
+
!query ||
|
|
97
|
+
matchesQuery(query, {
|
|
98
|
+
from: email.from,
|
|
99
|
+
to: email.to.join(' '),
|
|
100
|
+
cc: (archiveById.get(email.id)?.cc ?? []).join(' '),
|
|
101
|
+
bcc: (archiveById.get(email.id)?.bcc ?? []).join(' '),
|
|
102
|
+
subject: email.subject,
|
|
103
|
+
body: archiveById.get(email.id)?.text ?? '',
|
|
104
|
+
filenames: '',
|
|
105
|
+
date: new Date(email.createdAt).getTime(),
|
|
106
|
+
size: null,
|
|
107
|
+
read: true,
|
|
108
|
+
starred: Boolean(flagOf[email.id]?.starred),
|
|
109
|
+
hasAttachment: false,
|
|
110
|
+
labels: [],
|
|
111
|
+
folder: flagOf[email.id]?.trashed ? 'trash' : flagOf[email.id]?.archived ? 'archive' : 'sent',
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const truncated = Boolean(query) && archive.length >= SEARCH_LIMIT
|
|
116
|
+
return NextResponse.json({ ok: true, emails, flags, opens, searched: Boolean(query), truncated })
|
|
86
117
|
}
|
|
87
118
|
|
|
88
119
|
export async function PATCH(req: Request) {
|
|
@@ -4,10 +4,10 @@ import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
|
4
4
|
|
|
5
5
|
const COUNTS_TTL_MS = 20 * 1000
|
|
6
6
|
const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
|
|
7
|
-
async function cachedCounts(owner: string) {
|
|
7
|
+
async function cachedCounts(owner: string, fresh: boolean) {
|
|
8
8
|
const hit = countsCache.get(owner)
|
|
9
|
-
if (hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
10
|
-
const value = await countFoldersCached(owner)
|
|
9
|
+
if (!fresh && hit && Date.now() - hit.at < COUNTS_TTL_MS) return hit.value
|
|
10
|
+
const value = fresh ? await countFolders(owner) : await countFoldersCached(owner)
|
|
11
11
|
countsCache.set(owner, { at: Date.now(), value })
|
|
12
12
|
return value
|
|
13
13
|
}
|
|
@@ -20,6 +20,7 @@ export async function GET(req: Request) {
|
|
|
20
20
|
|
|
21
21
|
// Counts describe the signed-in mailbox, matching what the list will actually show.
|
|
22
22
|
const account = await resolveAccount(req)
|
|
23
|
-
const
|
|
23
|
+
const fresh = new URL(req.url).searchParams.get('fresh') === '1'
|
|
24
|
+
const counts = await cachedCounts(account.address ?? ' no-address', fresh)
|
|
24
25
|
return NextResponse.json({ ok: true, counts })
|
|
25
26
|
}
|
|
@@ -1,196 +1,12 @@
|
|
|
1
|
-
import { BRAND } from '@/lib/brand'
|
|
2
1
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
3
2
|
import { NextResponse } from 'next/server'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { isBrevoInbound, normalizeBrevoInbound
|
|
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
|
-
}
|
|
3
|
+
import { mailAuthGuard, isLocalOrigin, resolveAccount } from '@/lib/dev-auth'
|
|
4
|
+
import { searchInbox, appendEvent, appendInbound, recordContact, setInboundFlags, setInboundFlagsForThread, setInboundLabels, setThreadSnooze, setInboxOwner, readInbox, claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent, pruneWebhookEvents, type InboundFlags } from '@/lib/mailbox'
|
|
5
|
+
import { attributeOwner, forwardToAccounts, ingestReceived, parseSender } from '@/lib/receive'
|
|
6
|
+
import { isBrevoInbound, normalizeBrevoInbound } from '@/lib/mail-provider'
|
|
33
7
|
|
|
34
8
|
export const runtime = 'nodejs'
|
|
35
9
|
|
|
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
10
|
export async function GET(req: Request) {
|
|
195
11
|
const guard = await mailAuthGuard(req)
|
|
196
12
|
if (guard) return guard
|
|
@@ -210,7 +26,7 @@ export async function GET(req: Request) {
|
|
|
210
26
|
const threadId = params.get('thread') ?? undefined
|
|
211
27
|
if (text || limitRaw || offset || cursor || threadId) {
|
|
212
28
|
const folderParam = params.get('folder')
|
|
213
|
-
const folder = (['inbox', 'archive', 'trash', 'starred'] as const).find(f => f === folderParam)
|
|
29
|
+
const folder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam)
|
|
214
30
|
const { rows, total, nextCursor } = await searchInbox({
|
|
215
31
|
text,
|
|
216
32
|
owner: ownerFilter,
|
|
@@ -239,14 +55,34 @@ export async function GET(req: Request) {
|
|
|
239
55
|
export async function PATCH(req: Request) {
|
|
240
56
|
const guard = await mailAuthGuard(req)
|
|
241
57
|
if (guard) return guard
|
|
242
|
-
let body: { id?: string; ids?: string[]; labels?: string[]; owner?: string } & InboundFlags
|
|
58
|
+
let body: { id?: string; ids?: string[]; threadId?: string; labels?: string[]; owner?: string; snoozedUntil?: string | null } & InboundFlags
|
|
243
59
|
try {
|
|
244
60
|
body = await req.json()
|
|
245
61
|
} catch {
|
|
246
62
|
return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
|
|
247
63
|
}
|
|
248
64
|
const ids = body.ids ?? (body.id ? [body.id] : [])
|
|
249
|
-
if (!ids.length
|
|
65
|
+
if (!ids.length && !body.threadId) {
|
|
66
|
+
return NextResponse.json({ ok: false, error: 'id, ids or threadId is required' }, { status: 400 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Snooze belongs to the conversation, so it is set by thread and nothing else.
|
|
70
|
+
if (body.snoozedUntil !== undefined) {
|
|
71
|
+
if (!body.threadId) {
|
|
72
|
+
return NextResponse.json({ ok: false, error: 'snoozedUntil needs a threadId' }, { status: 400 })
|
|
73
|
+
}
|
|
74
|
+
const until = body.snoozedUntil
|
|
75
|
+
if (until !== null && Number.isNaN(Date.parse(until))) {
|
|
76
|
+
return NextResponse.json({ ok: false, error: 'snoozedUntil must be a date or null' }, { status: 400 })
|
|
77
|
+
}
|
|
78
|
+
const account = await resolveAccount(req)
|
|
79
|
+
const moved = await setThreadSnooze(
|
|
80
|
+
account.address ?? ' no-address',
|
|
81
|
+
body.threadId,
|
|
82
|
+
until === null ? null : new Date(until).toISOString(),
|
|
83
|
+
)
|
|
84
|
+
return NextResponse.json({ ok: true, messages: moved })
|
|
85
|
+
}
|
|
250
86
|
// Reassigning inbound mail to a mailbox is admin-only.
|
|
251
87
|
if (body.owner !== undefined) {
|
|
252
88
|
const account = await resolveAccount(req)
|
|
@@ -267,8 +103,13 @@ export async function PATCH(req: Request) {
|
|
|
267
103
|
if (body.archived !== undefined) flags.archived = body.archived
|
|
268
104
|
if (body.trashed !== undefined) flags.trashed = body.trashed
|
|
269
105
|
if (!Object.keys(flags).length) flags.read = true
|
|
106
|
+
if (body.threadId) {
|
|
107
|
+
const account = await resolveAccount(req)
|
|
108
|
+
const changed = await setInboundFlagsForThread(account.address ?? ' no-address', body.threadId, flags)
|
|
109
|
+
return NextResponse.json({ ok: true, ids: changed })
|
|
110
|
+
}
|
|
270
111
|
await Promise.all(ids.map(id => setInboundFlags(id, flags)))
|
|
271
|
-
return NextResponse.json({ ok: true })
|
|
112
|
+
return NextResponse.json({ ok: true, ids })
|
|
272
113
|
}
|
|
273
114
|
|
|
274
115
|
// Svix signature scheme used by Resend webhooks: HMAC-SHA256 over `${id}.${timestamp}.${payload}`
|
|
@@ -351,7 +192,7 @@ export async function POST(req: Request) {
|
|
|
351
192
|
// Brevo carries no delivery id, so the message id stands in: a replayed batch
|
|
352
193
|
// must not forward a second copy of mail already handled.
|
|
353
194
|
deliveryId = `brevo:${message.id}`
|
|
354
|
-
if (
|
|
195
|
+
if ((await claimWebhookEvent(deliveryId)) !== 'claimed') continue
|
|
355
196
|
const owner = await attributeOwner([...message.to, ...message.cc, ...message.bcc])
|
|
356
197
|
await appendInbound({ ...message, read: false, owner })
|
|
357
198
|
const sender = parseSender(message.from)
|
|
@@ -400,77 +241,15 @@ export async function POST(req: Request) {
|
|
|
400
241
|
// Keyed on the delivery id, which a retry reuses, so a redelivery of something we
|
|
401
242
|
// already finished is a no-op rather than a second forwarded copy.
|
|
402
243
|
const deliveryId = req.headers.get('svix-id') || `received:${emailId}`
|
|
403
|
-
|
|
404
|
-
|
|
244
|
+
const claim = await claimWebhookEvent(deliveryId)
|
|
245
|
+
if (claim === 'done') return NextResponse.json({ ok: true, duplicate: true })
|
|
246
|
+
if (claim === 'busy') {
|
|
247
|
+
// A 2xx here ends the provider's retries. Another attempt still holds this delivery;
|
|
248
|
+
// if it dies, the lease expires and the next retry takes over — so ask for one.
|
|
249
|
+
return NextResponse.json({ ok: false, error: 'Delivery is being handled; retry shortly' }, { status: 409 })
|
|
405
250
|
}
|
|
406
251
|
try {
|
|
407
|
-
|
|
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
|
-
)
|
|
252
|
+
await ingestReceived(emailId, data)
|
|
474
253
|
} catch (err) {
|
|
475
254
|
// Give the id back so the provider's retry is allowed to redo the work.
|
|
476
255
|
await releaseWebhookEvent(deliveryId)
|