@novacraft-engineering/mailbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,127 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { Resend } from 'resend'
3
+ import { mailAuthGuard } from '@/lib/dev-auth'
4
+ import { getSentAttachments, readSentMessage } from '@/lib/mailbox'
5
+
6
+ export const runtime = 'nodejs'
7
+
8
+ function getResend(): Resend | null {
9
+ const apiKey = process.env.RESEND_API_KEY
10
+ return apiKey ? new Resend(apiKey) : null
11
+ }
12
+
13
+ /**
14
+ * Imported archives were never in Resend, and a provider drops its copy eventually anyway;
15
+ * our own mail_sent row is the durable source, and the provider is asked only for the
16
+ * details it alone holds (attachment links, schedule, delivery state).
17
+ */
18
+ async function fromArchive(id: string) {
19
+ const stored = await readSentMessage(id).catch(() => null)
20
+ if (!stored) return null
21
+ return NextResponse.json({
22
+ ok: true,
23
+ email: {
24
+ id: stored.id,
25
+ from: stored.from,
26
+ to: stored.to,
27
+ cc: stored.cc,
28
+ bcc: stored.bcc,
29
+ replyTo: stored.replyTo[0] ?? null,
30
+ subject: stored.subject || '(no subject)',
31
+ html: stored.html,
32
+ text: stored.text,
33
+ createdAt: stored.createdAt,
34
+ scheduledAt: null,
35
+ lastEvent: stored.lastEvent ?? '',
36
+ attachments: [],
37
+ },
38
+ })
39
+ }
40
+
41
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
42
+ const guard = await mailAuthGuard(req)
43
+ if (guard) return guard
44
+ const { id } = await params
45
+ const resend = getResend()
46
+
47
+ if (id.startsWith('mbox-') || !resend) {
48
+ const archived = await fromArchive(id)
49
+ if (archived) return archived
50
+ return NextResponse.json({ ok: false, error: resend ? 'Not found' : 'RESEND_API_KEY not configured' }, { status: resend ? 404 : 500 })
51
+ }
52
+
53
+ const { data, error } = await resend.emails.get(id)
54
+ if (error) {
55
+ const archived = await fromArchive(id)
56
+ if (archived) return archived
57
+ return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
58
+ }
59
+
60
+ const stored = await getSentAttachments(id).catch(() => [])
61
+ const rawAttachments = stored.some(entry => entry.key)
62
+ ? stored
63
+ : ((await resend.emails.attachments.list({ emailId: id }).catch(() => null))?.data as unknown as {
64
+ data?: Array<Record<string, unknown>>
65
+ })?.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
+ }))
73
+
74
+ const item = data as unknown as Record<string, unknown>
75
+ return NextResponse.json({
76
+ ok: true,
77
+ email: {
78
+ id: String(item.id ?? id),
79
+ from: String(item.from ?? ''),
80
+ to: Array.isArray(item.to) ? (item.to as string[]) : [String(item.to ?? '')],
81
+ cc: Array.isArray(item.cc) ? (item.cc as string[]) : [],
82
+ bcc: Array.isArray(item.bcc) ? (item.bcc as string[]) : [],
83
+ replyTo: item.reply_to ? String(item.reply_to) : null,
84
+ subject: String(item.subject ?? '(no subject)'),
85
+ html: item.html ? String(item.html) : null,
86
+ text: item.text ? String(item.text) : null,
87
+ createdAt: String(item.created_at ?? ''),
88
+ scheduledAt: item.scheduled_at ? String(item.scheduled_at) : null,
89
+ lastEvent: String(item.last_event ?? ''),
90
+ attachments,
91
+ },
92
+ })
93
+ }
94
+
95
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
96
+ const guard = await mailAuthGuard(req)
97
+ if (guard) return guard
98
+ const resend = getResend()
99
+ if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
100
+
101
+ const { id } = await params
102
+ let body: { scheduledAt?: string }
103
+ try {
104
+ body = await req.json()
105
+ } catch {
106
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
107
+ }
108
+ if (!body.scheduledAt) {
109
+ return NextResponse.json({ ok: false, error: 'scheduledAt is required' }, { status: 400 })
110
+ }
111
+
112
+ const { error } = await resend.emails.update({ id, scheduledAt: body.scheduledAt })
113
+ if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
114
+ return NextResponse.json({ ok: true })
115
+ }
116
+
117
+ export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
118
+ const guard = await mailAuthGuard(req)
119
+ if (guard) return guard
120
+ const resend = getResend()
121
+ if (!resend) return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
122
+
123
+ const { id } = await params
124
+ const { error } = await resend.emails.cancel(id)
125
+ if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 502 })
126
+ return NextResponse.json({ ok: true })
127
+ }
@@ -0,0 +1,116 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { NextResponse } from 'next/server'
3
+ import { FORWARD_RECIPIENTS, mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { readSentFlags, setSentFlags, readPixelOpens, readSentMeta, setSentMetaOwner, listAccounts, readSentArchive, type SentFlags } from '@/lib/mailbox'
5
+
6
+ const SHARED_ADDRESS = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim().toLowerCase()
7
+
8
+ function fromAddressOf(email: SentEmail): string {
9
+ return (email.from.match(/<([^>]+)>/)?.[1] ?? email.from).trim().toLowerCase()
10
+ }
11
+
12
+ export const runtime = 'nodejs'
13
+
14
+ type SentEmail = { id: string; from: string; to: string[]; subject: string; createdAt: string; scheduledAt: string | null; lastEvent: string }
15
+
16
+ const KNOWN_AUTO_SUBJECTS = new Set([`Reset your ${BRAND.name} Mail password`])
17
+
18
+ /** Heuristic for pre-existing automated sends that predate owner/auto tagging. */
19
+ function looksAutomated(email: SentEmail): boolean {
20
+ if (email.subject.startsWith('Fwd:')) return true
21
+ if (KNOWN_AUTO_SUBJECTS.has(email.subject)) return true
22
+ const internal = new Set(
23
+ [...FORWARD_RECIPIENTS, ...(process.env.RESEND_TO ?? '').split(',')]
24
+ .map(addr => addr.trim().toLowerCase())
25
+ .filter(Boolean),
26
+ )
27
+ const recipients = email.to
28
+ .map(entry => (entry.match(/<([^>]+)>/)?.[1] ?? entry).trim().toLowerCase())
29
+ .filter(Boolean)
30
+ return recipients.length > 0 && recipients.every(addr => internal.has(addr))
31
+ }
32
+
33
+ export async function GET(req: Request) {
34
+ const guard = await mailAuthGuard(req)
35
+ if (guard) return guard
36
+
37
+ const account = await resolveAccount(req)
38
+ // Read our own archive rather than the provider's list: the history stays ours across
39
+ // a provider switch, and every send is written here at send time.
40
+ // Meta is looked up only for the archive rows being returned, so fetch those first.
41
+ const archive = await readSentArchive().catch(() => [])
42
+ const [flags, opens, sentMeta, accounts] = await Promise.all([
43
+ readSentFlags().catch(() => ({})),
44
+ readPixelOpens().catch(() => ({})),
45
+ readSentMeta(archive.map(item => item.id)).catch(() => ({})),
46
+ listAccounts().catch(() => []),
47
+ ])
48
+
49
+ const all: SentEmail[] = archive.map(item => ({
50
+ id: item.id,
51
+ from: item.from,
52
+ to: item.to,
53
+ subject: item.subject || '(no subject)',
54
+ createdAt: item.createdAt,
55
+ scheduledAt: null,
56
+ lastEvent: item.lastEvent ?? '',
57
+ }))
58
+
59
+ // Sent mail is scoped the same way the inbox is: to the signed-in mailbox, whatever
60
+ // the role. Administering accounts does not carry the right to read someone's sends.
61
+ const ownerScope: string = account.address ?? ' no-address'
62
+
63
+ // Ownership is the sender identity: the `from` address if it's an accessor's, else the
64
+ // shared inbox (admin). This attributes historical sends (all from hello@) correctly.
65
+ const accountAddresses = new Set(accounts.filter(entry => entry.address).map(entry => entry.address!.toLowerCase()))
66
+ const metaMap = sentMeta as Record<string, { owner: string | null; isAuto: boolean; inReplyTo: string | null }>
67
+ const sentOwner = (email: SentEmail): string => {
68
+ // An explicit assignment wins; otherwise the sender identity is the `from` address.
69
+ const assigned = metaMap[email.id]?.owner
70
+ if (assigned) return assigned
71
+ const from = fromAddressOf(email)
72
+ return accountAddresses.has(from) ? from : SHARED_ADDRESS
73
+ }
74
+
75
+ const emails = all
76
+ .filter(email => {
77
+ const meta = metaMap[email.id]
78
+ if (meta?.isAuto) return false // tagged automated → never in Sent
79
+ if (!meta && looksAutomated(email)) return false // pre-existing automated → excluded
80
+ if (ownerScope) return sentOwner(email) === ownerScope
81
+ return true
82
+ })
83
+ .map(email => ({ ...email, owner: sentOwner(email), inReplyTo: metaMap[email.id]?.inReplyTo ?? null }))
84
+
85
+ return NextResponse.json({ ok: true, emails, flags, opens })
86
+ }
87
+
88
+ export async function PATCH(req: Request) {
89
+ const guard = await mailAuthGuard(req)
90
+ if (guard) return guard
91
+ let body: { id?: string; ids?: string[]; owner?: string } & SentFlags
92
+ try {
93
+ body = await req.json()
94
+ } catch {
95
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
96
+ }
97
+ const ids = body.ids ?? (body.id ? [body.id] : [])
98
+ if (!ids.length) return NextResponse.json({ ok: false, error: 'id or ids is required' }, { status: 400 })
99
+
100
+ // Reassigning a message to a mailbox is admin-only.
101
+ if (body.owner !== undefined) {
102
+ const account = await resolveAccount(req)
103
+ if (account.role !== 'admin') {
104
+ return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
105
+ }
106
+ await Promise.all(ids.map(id => setSentMetaOwner(id, body.owner!)))
107
+ return NextResponse.json({ ok: true })
108
+ }
109
+
110
+ const flags: SentFlags = {}
111
+ if (body.starred !== undefined) flags.starred = body.starred
112
+ if (body.archived !== undefined) flags.archived = body.archived
113
+ if (body.trashed !== undefined) flags.trashed = body.trashed
114
+ await Promise.all(ids.map(id => setSentFlags(id, flags)))
115
+ return NextResponse.json({ ok: true })
116
+ }
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { readEvents } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ export async function GET(req: Request) {
8
+ const guard = await mailAuthGuard(req)
9
+ if (guard) return guard
10
+ const events = await readEvents()
11
+ return NextResponse.json({ ok: true, events })
12
+ }
@@ -0,0 +1,44 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { presign } from '@/lib/r2'
5
+
6
+ export const runtime = 'nodejs'
7
+
8
+ const TYPES: Record<string, string> = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' }
9
+ const MAX_BYTES = 5 * 1024 * 1024
10
+
11
+ export async function GET(req: Request) {
12
+ const key = new URL(req.url).searchParams.get('key')
13
+ if (!key || !/^fonts\/[a-f0-9]+\.(woff2|woff|ttf|otf)$/.test(key)) {
14
+ return NextResponse.json({ ok: false, error: 'Bad key' }, { status: 400 })
15
+ }
16
+ const upstream = await fetch(presign(key, 'GET', 300)).catch(() => null)
17
+ if (!upstream?.ok) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
18
+ return new NextResponse(upstream.body, {
19
+ headers: {
20
+ 'content-type': TYPES[key.split('.').pop() ?? ''] ?? 'application/octet-stream',
21
+ 'cache-control': 'public, max-age=31536000, immutable',
22
+ 'access-control-allow-origin': '*',
23
+ },
24
+ })
25
+ }
26
+
27
+ export async function POST(req: Request) {
28
+ const guard = await mailAuthGuard(req)
29
+ if (guard) return guard
30
+ const account = await resolveAccount(req)
31
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
32
+
33
+ const extension = (new URL(req.url).searchParams.get('ext') ?? '').toLowerCase()
34
+ if (!TYPES[extension]) return NextResponse.json({ ok: false, error: 'Use a WOFF2, WOFF, TTF or OTF font file.' }, { status: 400 })
35
+
36
+ const bytes = Buffer.from(await req.arrayBuffer())
37
+ if (bytes.length === 0) return NextResponse.json({ ok: false, error: 'That file is empty.' }, { status: 400 })
38
+ if (bytes.length > MAX_BYTES) return NextResponse.json({ ok: false, error: 'Keep the font under 5MB.' }, { status: 413 })
39
+
40
+ const key = `fonts/${createHash('sha256').update(bytes).digest('hex').slice(0, 24)}.${extension}`
41
+ const put = await fetch(presign(key, 'PUT', 300), { method: 'PUT', headers: { 'content-type': TYPES[extension] }, body: bytes }).catch(() => null)
42
+ if (!put?.ok) return NextResponse.json({ ok: false, error: 'Could not store that font.' }, { status: 502 })
43
+ return NextResponse.json({ ok: true, url: `/api/mail/fonts?key=${encodeURIComponent(key)}` })
44
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { getInboundAttachments } from '@/lib/mailbox'
4
+ import { getObject } from '@/lib/r2'
5
+
6
+ export const runtime = 'nodejs'
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ /**
10
+ * Serves attachment bytes from this domain rather than handing the browser a bucket or
11
+ * provider URL. Those hosts are a separate name to resolve and reach, and on the networks
12
+ * the office actually uses some of them do not resolve at all — the message opens, the
13
+ * attachment does not. The reader already reached this origin to load the page, so
14
+ * anything served from here is reachable by definition.
15
+ */
16
+ export async function GET(req: Request) {
17
+ const guard = await mailAuthGuard(req)
18
+ if (guard) return guard
19
+
20
+ const params = new URL(req.url).searchParams
21
+ const id = params.get('id')
22
+ const index = Number(params.get('index') ?? 0)
23
+ if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
24
+
25
+ // The key is read from the message rather than taken from the caller, so this cannot
26
+ // be pointed at an arbitrary object in the bucket.
27
+ const attachments = await getInboundAttachments(id).catch(() => [])
28
+ const entry = attachments[Number.isFinite(index) ? index : 0]
29
+ const key = entry && typeof entry.key === 'string' ? entry.key : ''
30
+ if (!key) return NextResponse.json({ ok: false, error: 'That file is not stored here.' }, { status: 404 })
31
+
32
+ const object = await getObject(key)
33
+ if (!object || !object.body) {
34
+ return NextResponse.json({ ok: false, error: 'That file could not be read.' }, { status: 502 })
35
+ }
36
+
37
+ const filename = String(entry.filename ?? 'attachment').replace(/["\\]/g, '')
38
+ // The preview and the download button share this route. `attachment` tells the browser
39
+ // to save rather than render, which turns a preview into a download — so the viewer
40
+ // asks for `inline` and only the download button gets the other.
41
+ const inline = params.get('inline') === '1'
42
+ return new Response(object.body, {
43
+ headers: {
44
+ 'content-type': String(entry.contentType ?? object.headers.get('content-type') ?? 'application/octet-stream'),
45
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`,
46
+ ...(object.headers.get('content-length') ? { 'content-length': object.headers.get('content-length')! } : {}),
47
+ 'cache-control': 'private, max-age=3600',
48
+ },
49
+ })
50
+ }
@@ -0,0 +1,54 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard } from '@/lib/dev-auth'
4
+ import { getInboundAttachments } from '@/lib/mailbox'
5
+ import { getObject, putObject } from '@/lib/r2'
6
+
7
+ export const runtime = 'nodejs'
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ /**
11
+ * Copies the files on a received message into the outgoing space, so they can be
12
+ * forwarded.
13
+ *
14
+ * The send route will only attach keys under `outgoing/`, which is what stops a caller
15
+ * naming any object in the bucket. Rather than widen that, the copy happens here: the
16
+ * key is read from the message itself, never from the request, so the only files that
17
+ * can be copied are the ones actually on a message this caller may read.
18
+ */
19
+ export async function POST(req: Request) {
20
+ const guard = await mailAuthGuard(req)
21
+ if (guard) return guard
22
+
23
+ let body: { id?: string }
24
+ try {
25
+ body = await req.json()
26
+ } catch {
27
+ return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
28
+ }
29
+
30
+ const id = String(body.id ?? '')
31
+ if (!id) return NextResponse.json({ ok: false, error: 'Which message?' }, { status: 400 })
32
+
33
+ const held = await getInboundAttachments(id).catch(() => [])
34
+ const copied: Array<{ filename: string; size: number; contentType?: string; key: string }> = []
35
+
36
+ for (const entry of held) {
37
+ const sourceKey = typeof entry.key === 'string' ? entry.key : ''
38
+ if (!sourceKey) continue
39
+
40
+ const object = await getObject(sourceKey)
41
+ if (!object) continue
42
+ const bytes = Buffer.from(await object.arrayBuffer())
43
+
44
+ const filename = String(entry.filename ?? 'attachment')
45
+ const safe = filename.replace(/[^\w.\- ]+/g, '_').slice(0, 120)
46
+ const key = `outgoing/${randomBytes(9).toString('hex')}/${safe}`
47
+ const contentType = typeof entry.contentType === 'string' ? entry.contentType : undefined
48
+ if (!(await putObject(key, bytes, contentType))) continue
49
+
50
+ copied.push({ filename, size: bytes.length, contentType, key })
51
+ }
52
+
53
+ return NextResponse.json({ ok: true, attachments: copied })
54
+ }
@@ -0,0 +1,99 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { getInboundAttachments, resolveInboundBody, setInboundAttachments } from '@/lib/mailbox'
4
+ import { isEmbedded, referencedCids } from '@/lib/attachments'
5
+
6
+ export const runtime = 'nodejs'
7
+
8
+ /**
9
+ * Our own rehosted copy wins over Resend's: the provider's download URLs expire and vanish
10
+ * with the account, and imported archives were never in Resend to begin with.
11
+ */
12
+ export async function GET(req: Request) {
13
+ const guard = await mailAuthGuard(req)
14
+ if (guard) return guard
15
+
16
+ const id = new URL(req.url).searchParams.get('id')
17
+ if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
18
+
19
+ // Anything we hold is served back through this domain. A bucket link and a provider
20
+ // link are both hosts the reader's network has to reach separately, and on some of
21
+ // those networks they cannot be reached at all.
22
+ const held = await getInboundAttachments(id).catch(() => [])
23
+ // The body decides which images are decoration. Read once, not once per file.
24
+ const body = await resolveInboundBody(id).catch(() => ({ html: null, text: null }))
25
+ const referenced = referencedCids(body.html)
26
+ const stored = held
27
+ .map((entry, index) => ({ entry, index }))
28
+ .filter(({ entry }) => typeof entry.key === 'string' && entry.key)
29
+ .filter(({ entry }) => !isEmbedded(entry, referenced))
30
+ .map(({ entry, index }) => ({
31
+ filename: String(entry.filename ?? 'attachment'),
32
+ size: Number(entry.size ?? 0),
33
+ contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined,
34
+ downloadUrl: `/api/mail/inbox/attachments/download?id=${encodeURIComponent(id)}&index=${index}`,
35
+ }))
36
+ // A file too big to send was put behind a share page instead. We hold no bytes for it,
37
+ // but it is not lost: it has somewhere to go, and a tile with nowhere to go is a dead end.
38
+ const shared = held
39
+ .map((entry, index) => ({ entry, index }))
40
+ .filter(({ entry }) => typeof entry.shareId === 'string' && entry.shareId && !entry.key)
41
+ .map(({ entry }) => ({
42
+ filename: String(entry.filename ?? 'attachment'),
43
+ size: Number(entry.size ?? 0),
44
+ contentType: typeof entry.contentType === 'string' ? entry.contentType : undefined,
45
+ shareId: String(entry.shareId),
46
+ downloadUrl: `/share/${encodeURIComponent(String(entry.shareId))}`,
47
+ }))
48
+ if (stored.length || shared.length) return NextResponse.json({ ok: true, attachments: [...stored, ...shared] })
49
+
50
+ // Nothing of ours yet: this message predates the copy-on-arrival. Pull the bytes from
51
+ // the provider now and keep them, so the next reader is served from here and the file
52
+ // survives the provider's own retention window.
53
+ const apiKey = process.env.RESEND_API_KEY
54
+ if (!apiKey) return NextResponse.json({ ok: true, attachments: [] })
55
+
56
+ const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}/attachments`, {
57
+ headers: { authorization: `Bearer ${apiKey}` },
58
+ })
59
+ if (!response.ok) return NextResponse.json({ ok: true, attachments: [] })
60
+
61
+ const payload = (await response.json()) as { data?: Array<Record<string, unknown>> }
62
+ const listed = payload.data ?? []
63
+ const { putObject } = await import('@/lib/r2')
64
+
65
+ const kept = await Promise.all(
66
+ listed.map(async (entry, index) => {
67
+ const filename = String(entry.filename ?? 'attachment')
68
+ const contentType = entry.content_type ? String(entry.content_type) : undefined
69
+ const source = entry.download_url ? String(entry.download_url) : ''
70
+ const contentId = entry.content_id ? String(entry.content_id).replace(/^<|>$/g, '') : undefined
71
+ const meta: Record<string, unknown> = { filename, contentType, size: Number(entry.size ?? 0), contentId }
72
+ if (!source) return meta
73
+ try {
74
+ const binary = await fetch(source)
75
+ if (!binary.ok) return meta
76
+ const bytes = Buffer.from(await binary.arrayBuffer())
77
+ const safeName = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
78
+ const key = `attachments/${id}/${index}-${safeName}`
79
+ if (await putObject(key, bytes, contentType)) return { ...meta, size: bytes.length, key }
80
+ } catch {
81
+ // Fall through: the file is still listed, just not ours yet.
82
+ }
83
+ return meta
84
+ }),
85
+ )
86
+
87
+ if (kept.some(entry => entry.key)) await setInboundAttachments(id, kept).catch(() => {})
88
+
89
+ const attachments = kept.map((entry, index) => ({ entry, index }))
90
+ .filter(({ entry }) => !isEmbedded(entry, referenced))
91
+ .map(({ entry, index }) => ({
92
+ filename: String(entry.filename ?? 'attachment'),
93
+ size: Number(entry.size ?? 0),
94
+ // Only ours are offered. A provider link that the reader's network cannot reach is
95
+ // worse than none: it looks like the app is broken rather than the file being absent.
96
+ downloadUrl: entry.key ? `/api/mail/inbox/attachments/download?id=${encodeURIComponent(id)}&index=${index}` : '',
97
+ }))
98
+ return NextResponse.json({ ok: true, attachments })
99
+ }
@@ -0,0 +1,14 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+ import { resolveInboundBody } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ export async function GET(req: Request) {
8
+ const guard = await mailAuthGuard(req)
9
+ if (guard) return guard
10
+ const id = new URL(req.url).searchParams.get('id')
11
+ if (!id) return NextResponse.json({ ok: false, error: 'id is required' }, { status: 400 })
12
+ const body = await resolveInboundBody(id).catch(() => ({ html: null, text: null }))
13
+ return NextResponse.json({ ok: true, ...body })
14
+ }
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { countFolders, countFoldersCached } from '@/lib/mailbox'
4
+
5
+ const COUNTS_TTL_MS = 20 * 1000
6
+ const countsCache = new Map<string, { at: number; value: Awaited<ReturnType<typeof countFolders>> }>()
7
+ async function cachedCounts(owner: string) {
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)
11
+ countsCache.set(owner, { at: Date.now(), value })
12
+ return value
13
+ }
14
+
15
+ export const runtime = 'nodejs'
16
+
17
+ export async function GET(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+
21
+ // Counts describe the signed-in mailbox, matching what the list will actually show.
22
+ const account = await resolveAccount(req)
23
+ const counts = await cachedCounts(account.address ?? ' no-address')
24
+ return NextResponse.json({ ok: true, counts })
25
+ }