@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.
Files changed (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. 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. Coolify auto-deploys `master` on push, and a single
20
- push updates **every** live mailbox at once, not just the one you are working on.
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 { Resend } from 'resend'
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 apiKey = process.env.RESEND_API_KEY
106
- if (apiKey) {
107
- const from = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '')
108
- const origin = process.env.MAIL_PUBLIC_URL?.replace(/\/$/, '') || new URL(req.url).origin
109
- const inviteUrl = `${origin}/mail/reset?token=${token}&invite=1`
110
- const resend = new Resend(apiKey)
111
- try {
112
- const { data } = await resend.emails.send({
113
- from: `${BRAND.name} Mail <${from}>`,
114
- to: [email],
115
- subject: `You've been invited to ${BRAND.name} Mail`,
116
- text: `${inviter.email} invited you to ${BRAND.name} Mail.\n\nSet your password to get started: ${inviteUrl}\n\nThis link expires in 7 days.`,
117
- html: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6;color:#1A1030;">
118
- <p style="margin:0 0 12px;color:${BRAND.colors.accent};font-weight:600;">You've been invited to ${BRAND.name} Mail</p>
119
- <p style="margin:0 0 16px;">${name ? `Hi ${name}, ` : ''}you've been given access to ${BRAND.name} Mail. Set a password to get started.</p>
120
- <a href="${inviteUrl}" style="display:inline-block;background:${BRAND.colors.accent};color:#fff;text-decoration:none;font-weight:600;padding:11px 20px;border-radius:8px;">Set your password</a>
121
- <p style="margin:16px 0 0;color:#8E84A8;font-size:13px;">This invite link expires in 7 days.</p>
122
- </div>`,
123
- })
124
- if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
125
- } catch (err) {
126
- console.warn('[mail] invite email failed:', err)
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
- // Served through this domain rather than the provider's host, which does not resolve
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().catch(() => [])
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
- return NextResponse.json({ ok: true, emails, flags, opens })
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 counts = await cachedCounts(account.address ?? ' no-address')
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 { 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
- }
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, '&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
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) return NextResponse.json({ ok: false, error: 'id or ids is required' }, { status: 400 })
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 (!(await claimWebhookEvent(deliveryId))) continue
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
- if (!(await claimWebhookEvent(deliveryId))) {
404
- return NextResponse.json({ ok: true, duplicate: true })
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
- 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
- )
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)