@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,30 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { migrateBodiesToBucket } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+ export const maxDuration = 300
7
+
8
+ /**
9
+ * Maintenance is driven by a script rather than a browser, so it carries its own token —
10
+ * separate from the session secret, which cannot be rotated without signing everybody out.
11
+ */
12
+ function authorised(req: Request): boolean {
13
+ const secret = process.env.MAINTENANCE_TOKEN
14
+ if (!secret) return false
15
+ const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
16
+ if (token.length !== secret.length) return false
17
+ try {
18
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ export async function POST(req: Request) {
25
+ if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
26
+ const url = new URL(req.url)
27
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 40) || 40, 200)
28
+ const result = await migrateBodiesToBucket(limit, url.searchParams.get('count') === '1')
29
+ return NextResponse.json({ ok: true, ...result })
30
+ }
@@ -0,0 +1,25 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { backfillListColumns } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+ export const maxDuration = 300
7
+
8
+ function authorised(req: Request): boolean {
9
+ const secret = process.env.MAINTENANCE_TOKEN
10
+ if (!secret) return false
11
+ const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
12
+ if (token.length !== secret.length) return false
13
+ try {
14
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ export async function POST(req: Request) {
21
+ if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
22
+ const limit = Math.min(Number(new URL(req.url).searchParams.get('limit') ?? 500) || 500, 2000)
23
+ const result = await backfillListColumns(limit)
24
+ return NextResponse.json({ ok: true, ...result })
25
+ }
@@ -0,0 +1,32 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { assignThreads, refreshThreadsFrom } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+ export const maxDuration = 300
7
+
8
+ function authorised(req: Request): boolean {
9
+ const secret = process.env.MAINTENANCE_TOKEN
10
+ if (!secret) return false
11
+ const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
12
+ if (token.length !== secret.length) return false
13
+ try {
14
+ return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ export async function POST(req: Request) {
21
+ if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
22
+ const url = new URL(req.url)
23
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 300) || 300, 1000)
24
+ if (url.searchParams.get('phase') === 'refresh') {
25
+ const owner = url.searchParams.get('afterOwner')
26
+ const threadId = url.searchParams.get('afterThread')
27
+ const result = await refreshThreadsFrom(owner && threadId ? { owner, threadId } : null, limit)
28
+ return NextResponse.json({ ok: true, ...result })
29
+ }
30
+ const result = await assignThreads(limit)
31
+ return NextResponse.json({ ok: true, ...result })
32
+ }
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { usingDefaultPassword } 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 account = await resolveAccount(req)
11
+ const defaultPassword = account.email ? await usingDefaultPassword(account.email) : false
12
+ return NextResponse.json({ ok: true, ...account, defaultPassword })
13
+ }
@@ -0,0 +1,37 @@
1
+ import { randomBytes } 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 MAX_BYTES = 2 * 1024 * 1024 * 1024
9
+
10
+ /**
11
+ * Hands back a presigned PUT so the browser uploads straight to the bucket. The bytes
12
+ * never pass through a serverless function, which has a request-body ceiling far below
13
+ * what people attach, and the URL is short-lived so it is not a standing grant.
14
+ */
15
+ export async function POST(req: Request) {
16
+ const guard = await mailAuthGuard(req)
17
+ if (guard) return guard
18
+ const account = await resolveAccount(req)
19
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
20
+
21
+ let body: { filename?: string; size?: number; contentType?: string }
22
+ try {
23
+ body = await req.json()
24
+ } catch {
25
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
26
+ }
27
+
28
+ const filename = (body.filename ?? '').trim()
29
+ if (!filename) return NextResponse.json({ ok: false, error: 'A filename is required.' }, { status: 400 })
30
+ if ((body.size ?? 0) > MAX_BYTES) {
31
+ return NextResponse.json({ ok: false, error: 'That file is larger than 2GB.' }, { status: 413 })
32
+ }
33
+
34
+ const safe = filename.replace(/[^\w.\- ]+/g, '_').slice(0, 120)
35
+ const key = `outgoing/${randomBytes(9).toString('hex')}/${safe}`
36
+ return NextResponse.json({ ok: true, key, uploadUrl: presign(key, 'PUT', 3600) })
37
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount, verifyMailAuth } from '@/lib/dev-auth'
3
+ import { clearResetTokens, setAccountPassword } from '@/lib/mailbox'
4
+ import { hashPassword, passwordProblem } from '@/lib/password'
5
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
6
+ import { attachSession, issueSession, passwordFingerprint } from '@/lib/session'
7
+
8
+ export const runtime = 'nodejs'
9
+
10
+ export async function POST(req: Request) {
11
+ const guard = await mailAuthGuard(req)
12
+ if (guard) return guard
13
+
14
+ // This endpoint verifies the current password, so it is a guessing oracle too.
15
+ const limited = rateLimit(clientKey(req, 'password'), 10, 15 * 60 * 1000)
16
+ if (limited) return limited
17
+
18
+ let body: { current?: string; next?: string }
19
+ try {
20
+ body = await req.json()
21
+ } catch {
22
+ return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
23
+ }
24
+
25
+ const current = String(body.current ?? '')
26
+ const next = String(body.next ?? '')
27
+
28
+ if (next === current) {
29
+ return NextResponse.json(
30
+ { ok: false, error: 'That is the password you are already using.' },
31
+ { status: 400 },
32
+ )
33
+ }
34
+
35
+ const account = await resolveAccount(req)
36
+ if (!account.email) {
37
+ return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
38
+ }
39
+
40
+ const problem = passwordProblem(next, account.email)
41
+ if (problem) {
42
+ return NextResponse.json({ ok: false, error: problem }, { status: 400 })
43
+ }
44
+
45
+ // Re-check the current password rather than trusting the session header, so a
46
+ // borrowed unlocked screen cannot be used to take the account over.
47
+ const confirmed = await verifyMailAuth(account.email, current)
48
+ if (!confirmed.ok) {
49
+ return NextResponse.json(
50
+ { ok: false, error: 'That is not your current password.' },
51
+ { status: 403 },
52
+ )
53
+ }
54
+
55
+ const passwordHash = await hashPassword(next)
56
+ await setAccountPassword(account.email, passwordHash)
57
+ // Any reset link that was already in flight is now a way back in for whoever asked for it.
58
+ await clearResetTokens(account.email).catch(() => {})
59
+
60
+ // Every other signed-in device fails its fingerprint check from here on; this response
61
+ // carries the replacement so the person who made the change stays signed in.
62
+ const session = issueSession(account.email, passwordFingerprint(passwordHash))
63
+ return attachSession(NextResponse.json({ ok: true }), session)
64
+ }
@@ -0,0 +1,20 @@
1
+ import { markPixelOpened } from '@/lib/mailbox'
2
+
3
+ export const runtime = 'nodejs'
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
7
+
8
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params
10
+ if (id) await markPixelOpened(id).catch(() => {})
11
+ return new Response(PIXEL, {
12
+ headers: {
13
+ 'Content-Type': 'image/gif',
14
+ 'Content-Length': String(PIXEL.length),
15
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
16
+ Pragma: 'no-cache',
17
+ Expires: '0',
18
+ },
19
+ })
20
+ }
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { deletePushSubscription, savePushSubscription, type PushSubscriptionRow } 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
+ return NextResponse.json({ ok: true, publicKey: process.env.VAPID_PUBLIC_KEY ?? null })
11
+ }
12
+
13
+ export async function POST(req: Request) {
14
+ const guard = await mailAuthGuard(req)
15
+ if (guard) return guard
16
+ const account = await resolveAccount(req)
17
+ const owner = account.address ?? account.email
18
+ if (!owner) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
19
+
20
+ let body: Partial<PushSubscriptionRow>
21
+ try {
22
+ body = await req.json()
23
+ } catch {
24
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
25
+ }
26
+ const endpoint = typeof body.endpoint === 'string' ? body.endpoint : ''
27
+ const p256dh = typeof body.keys?.p256dh === 'string' ? body.keys.p256dh : ''
28
+ const auth = typeof body.keys?.auth === 'string' ? body.keys.auth : ''
29
+ if (!/^https:\/\//.test(endpoint) || !p256dh || !auth) {
30
+ return NextResponse.json({ ok: false, error: 'That is not a push subscription.' }, { status: 400 })
31
+ }
32
+ await savePushSubscription(owner, { endpoint, keys: { p256dh, auth } })
33
+ return NextResponse.json({ ok: true })
34
+ }
35
+
36
+ export async function DELETE(req: Request) {
37
+ const guard = await mailAuthGuard(req)
38
+ if (guard) return guard
39
+ const endpoint = new URL(req.url).searchParams.get('endpoint') ?? ''
40
+ if (endpoint) await deletePushSubscription(endpoint)
41
+ return NextResponse.json({ ok: true })
42
+ }
@@ -0,0 +1,58 @@
1
+ import { NextResponse } from 'next/server'
2
+ import {
3
+ academyFollowupSubject,
4
+ contactFollowupSubject,
5
+ renderAcademyFollowupEmail,
6
+ renderContactFollowupEmail,
7
+ } from '@/lib/emails'
8
+ import { mailAuthGuard } from '@/lib/dev-auth'
9
+
10
+ export const runtime = 'nodejs'
11
+
12
+ type Body = {
13
+ template?: 'academy' | 'contact'
14
+ firstName?: string
15
+ courses?: string
16
+ cohortDate?: string
17
+ projectType?: string
18
+ includeAcademy?: boolean
19
+ }
20
+
21
+ export async function POST(req: Request) {
22
+ const guard = await mailAuthGuard(req)
23
+ if (guard) return guard
24
+
25
+ let body: Body
26
+ try {
27
+ body = await req.json()
28
+ } catch {
29
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
30
+ }
31
+
32
+ const firstName = (body.firstName ?? '').trim() || 'there'
33
+
34
+ if (body.template === 'academy') {
35
+ const courses = (body.courses ?? '').trim()
36
+ const cohortDate = (body.cohortDate ?? '').trim()
37
+ if (!courses || !cohortDate) {
38
+ return NextResponse.json({ ok: false, error: 'Pick at least one course and a cohort date' }, { status: 400 })
39
+ }
40
+ const html = renderAcademyFollowupEmail({ firstName, courses, cohortDate })
41
+ return NextResponse.json({ ok: true, subject: academyFollowupSubject(courses), html })
42
+ }
43
+
44
+ if (body.template === 'contact') {
45
+ const projectType = (body.projectType ?? '').trim()
46
+ if (!projectType) {
47
+ return NextResponse.json({ ok: false, error: 'Set a project type' }, { status: 400 })
48
+ }
49
+ const html = renderContactFollowupEmail({
50
+ firstName,
51
+ projectType,
52
+ includeAcademy: body.includeAcademy ? 'true' : 'false',
53
+ })
54
+ return NextResponse.json({ ok: true, subject: contactFollowupSubject(projectType), html })
55
+ }
56
+
57
+ return NextResponse.json({ ok: false, error: 'Unknown template' }, { status: 400 })
58
+ }
@@ -0,0 +1,65 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { Resend } from 'resend'
5
+ import { isMailAccount } from '@/lib/dev-auth'
6
+ import { createResetToken, recordSentMeta } from '@/lib/mailbox'
7
+ import { publicOrigin } from '@/lib/public-url'
8
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
9
+
10
+ export const runtime = 'nodejs'
11
+
12
+ const RESET_TTL_MS = 30 * 60 * 1000
13
+
14
+ export async function POST(req: Request) {
15
+ const limited = rateLimit(clientKey(req, 'request-reset'), 5, 60 * 60 * 1000)
16
+ if (limited) return limited
17
+
18
+ let body: { email?: string }
19
+ try {
20
+ body = await req.json()
21
+ } catch {
22
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
23
+ }
24
+
25
+ const email = (body.email ?? '').trim().toLowerCase()
26
+
27
+ // Also capped per address, so a rotating-IP caller cannot bury someone in reset mail.
28
+ const perAddress = rateLimit(`request-reset-address:${email}`, 5, 60 * 60 * 1000)
29
+ if (perAddress) return NextResponse.json({ ok: true })
30
+ // Always answer ok so we never reveal which addresses are valid accounts.
31
+ if (!(await isMailAccount(email))) {
32
+ return NextResponse.json({ ok: true })
33
+ }
34
+
35
+ const token = randomBytes(32).toString('hex')
36
+ await createResetToken(email, token, Date.now() + RESET_TTL_MS)
37
+
38
+ const apiKey = process.env.RESEND_API_KEY
39
+ if (apiKey) {
40
+ const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail)
41
+ .replace(/^.*<|>$/g, '')
42
+ .trim()
43
+ const resetUrl = `${publicOrigin(req)}/mail/reset?token=${token}`
44
+ const resend = new Resend(apiKey)
45
+ try {
46
+ const { data } = await resend.emails.send({
47
+ from: `${BRAND.name} Mail <${from}>`,
48
+ to: [email],
49
+ subject: `Reset your ${BRAND.name} Mail password`,
50
+ text: `Someone requested a password reset for ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
51
+ html: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6;color:#1A1030;">
52
+ <p style="margin:0 0 12px;color:${BRAND.colors.accent};font-weight:600;">Reset your ${BRAND.name} Mail password</p>
53
+ <p style="margin:0 0 16px;">Click below to set a new password. The link expires in 30 minutes.</p>
54
+ <a href="${resetUrl}" style="display:inline-block;background:${BRAND.colors.accent};color:#fff;text-decoration:none;font-weight:600;padding:11px 20px;border-radius:8px;">Set a new password</a>
55
+ <p style="margin:16px 0 0;color:#8E84A8;font-size:13px;">Didn't request this? Ignore this email — your password won't change.</p>
56
+ </div>`,
57
+ })
58
+ if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
59
+ } catch (err) {
60
+ console.warn('[mail] reset email failed:', err)
61
+ }
62
+ }
63
+
64
+ return NextResponse.json({ ok: true })
65
+ }
@@ -0,0 +1,46 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { hashPassword, passwordProblem } from '@/lib/password'
3
+ import { resetPasswordWithToken, resetTokenEmail } from '@/lib/mailbox'
4
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
5
+ import { attachSession, issueSession, passwordFingerprint } from '@/lib/session'
6
+
7
+ export const runtime = 'nodejs'
8
+
9
+ export async function POST(req: Request) {
10
+ // A reset token is a bearer credential, so guessing one needs a ceiling like login has.
11
+ const limited = rateLimit(clientKey(req, 'reset'), 10, 15 * 60 * 1000)
12
+ if (limited) return limited
13
+
14
+ let body: { token?: string; password?: string }
15
+ try {
16
+ body = await req.json()
17
+ } catch {
18
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
19
+ }
20
+
21
+ const token = (body.token ?? '').trim()
22
+ const password = body.password ?? ''
23
+ if (!token) {
24
+ return NextResponse.json({ ok: false, error: 'Missing reset token' }, { status: 400 })
25
+ }
26
+
27
+ // Resolved before the token is spent so a rejected password does not burn the link.
28
+ const owner = await resetTokenEmail(token)
29
+ if (!owner) {
30
+ return NextResponse.json({ ok: false, error: 'This reset link is invalid or has expired' }, { status: 400 })
31
+ }
32
+
33
+ const problem = passwordProblem(password, owner)
34
+ if (problem) {
35
+ return NextResponse.json({ ok: false, error: problem }, { status: 400 })
36
+ }
37
+
38
+ const passwordHash = await hashPassword(password)
39
+ const email = await resetPasswordWithToken(token, passwordHash)
40
+ if (!email) {
41
+ return NextResponse.json({ ok: false, error: 'This reset link is invalid or has expired' }, { status: 400 })
42
+ }
43
+
44
+ const session = issueSession(email, passwordFingerprint(passwordHash))
45
+ return attachSession(NextResponse.json({ ok: true, email }), session)
46
+ }
@@ -0,0 +1,205 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { NextResponse } from 'next/server'
4
+ import { sendMail } from '@/lib/mail-provider'
5
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
6
+ import { presign } from '@/lib/r2'
7
+ import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
8
+ import { publicOrigin } from '@/lib/public-url'
9
+
10
+ export const runtime = 'nodejs'
11
+
12
+ type SendBody = {
13
+ to?: string[]
14
+ cc?: string[]
15
+ bcc?: string[]
16
+ replyTo?: string
17
+ fromName?: string
18
+ subject?: string
19
+ html?: string
20
+ text?: string
21
+ scheduledAt?: string
22
+ inReplyTo?: string
23
+ /** Admin only: the mailbox to send on behalf of. */
24
+ actAs?: string
25
+ attachments?: Array<{ filename: string; content?: string; path?: string; key?: string }>
26
+ }
27
+
28
+ export async function POST(req: Request) {
29
+ const guard = await mailAuthGuard(req)
30
+ if (guard) return guard
31
+
32
+ const account = await resolveAccount(req)
33
+
34
+ const apiKey = process.env.RESEND_API_KEY
35
+ if (!apiKey) {
36
+ return NextResponse.json({ ok: false, error: 'RESEND_API_KEY not configured' }, { status: 500 })
37
+ }
38
+
39
+ let body: SendBody
40
+ try {
41
+ body = await req.json()
42
+ } catch {
43
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
44
+ }
45
+
46
+ const recipients = (body.to ?? []).filter(Boolean)
47
+
48
+ // Send from the accessor's personal address so replies route back to their mailbox.
49
+ //
50
+ // Mail goes out as the account that is signed in. Sending as somebody else went with
51
+ // browsing their mailbox, which no longer happens; the claim is refused here rather
52
+ // than trusted, because the client can ask to send as anyone.
53
+ const actingAddress: string | null = null
54
+ const requestedActAs = body.actAs?.trim().toLowerCase()
55
+ if (requestedActAs && requestedActAs !== account.address?.toLowerCase()) {
56
+ return NextResponse.json(
57
+ { ok: false, error: 'You can only send from your own address.' },
58
+ { status: 403 },
59
+ )
60
+ }
61
+
62
+ const fromAddress =
63
+ actingAddress ?? account.address ?? process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail
64
+
65
+ // Sending to your own address loops the message back through the inbound
66
+ // webhook and lands it in the mailbox it came from, which reads as a delivery
67
+ // failure to the person who sent it. Refuse it outright.
68
+ //
69
+ // Checked against the address actually being sent from, not account.address: an
70
+ // accessor with no address of their own still sends as the shared inbox, and
71
+ // that send would loop just the same.
72
+ const senderAddress = fromAddress.replace(/^.*<|>$/g, '').trim().toLowerCase()
73
+ if (senderAddress) {
74
+ const everyRecipient = [...recipients, ...(body.cc ?? []), ...(body.bcc ?? [])]
75
+ .map(entry => String(entry).replace(/^.*<|>$/g, '').trim().toLowerCase())
76
+ if (everyRecipient.includes(senderAddress)) {
77
+ return NextResponse.json(
78
+ { ok: false, error: 'You cannot send a message to your own address.' },
79
+ { status: 400 },
80
+ )
81
+ }
82
+ }
83
+
84
+ if (!recipients.length) {
85
+ return NextResponse.json({ ok: false, error: 'At least one recipient is required' }, { status: 400 })
86
+ }
87
+ if (!body.subject?.trim()) {
88
+ return NextResponse.json({ ok: false, error: 'Subject is required' }, { status: 400 })
89
+ }
90
+ if (!body.html?.trim() && !body.text?.trim()) {
91
+ return NextResponse.json({ ok: false, error: 'Email body is required' }, { status: 400 })
92
+ }
93
+
94
+ const rawAttachments = (body.attachments ?? []).filter(entry => entry.filename && (entry.content || entry.path || entry.key))
95
+ if (rawAttachments.length && body.scheduledAt) {
96
+ // Resend rejects attachments on scheduled sends — surface it before the API does.
97
+ return NextResponse.json(
98
+ { ok: false, error: 'Resend does not support attachments on scheduled emails — send now or drop the attachments' },
99
+ { status: 400 },
100
+ )
101
+ }
102
+
103
+ // Embed each attachment as base64 content. Resend fetches `path` URLs unreliably,
104
+ // so we pull the blob server-side (no browser body limit) and hand Resend the bytes.
105
+ let attachments: Array<{ filename: string; content: string }>
106
+ try {
107
+ attachments = await Promise.all(
108
+ rawAttachments.map(async entry => {
109
+ if (entry.content) return { filename: entry.filename, content: entry.content }
110
+
111
+ // Attachments live in the bucket; the key is signed here rather than trusting a
112
+ // URL from the client, so a caller cannot point this at somewhere it should not read.
113
+ let source: URL
114
+ if (entry.key) {
115
+ if (!/^outgoing\/[a-f0-9]{18}\/[\w.\- ]{1,120}$/.test(entry.key)) {
116
+ throw new Error(`Unsupported attachment source for ${entry.filename}`)
117
+ }
118
+ source = new URL(presign(entry.key, 'GET', 300))
119
+ } else {
120
+ source = new URL(entry.path!)
121
+ if (source.protocol !== 'https:' || !source.hostname.endsWith('.public.blob.vercel-storage.com')) {
122
+ throw new Error(`Unsupported attachment source for ${entry.filename}`)
123
+ }
124
+ }
125
+ const response = await fetch(source)
126
+ if (!response.ok) throw new Error(`Could not fetch attachment ${entry.filename}`)
127
+ const buffer = Buffer.from(await response.arrayBuffer())
128
+ return { filename: entry.filename, content: buffer.toString('base64') }
129
+ }),
130
+ )
131
+ } catch (fetchError) {
132
+ return NextResponse.json({ ok: false, error: (fetchError as Error).message }, { status: 502 })
133
+ }
134
+
135
+ const bareAddress = fromAddress.includes('<') ? fromAddress : `${BRAND.name} <${fromAddress}>`
136
+ const from = body.fromName?.trim()
137
+ ? `${body.fromName.trim().replace(/[<>"]/g, '')} <${bareAddress.replace(/^.*<|>$/g, '')}>`
138
+ : bareAddress
139
+ const replyTo = body.replyTo?.trim() || account.address || undefined
140
+
141
+ // Open-tracking pixel: a 1x1 image whose load hits our endpoint, so we can tell
142
+ // an HTML email was opened independently of Resend's own tracking.
143
+ const origin = publicOrigin(req)
144
+ const pixelId = randomUUID()
145
+ const trackedHtml = body.html?.trim()
146
+ ? `${body.html.trim()}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
147
+ : null
148
+
149
+ // Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
150
+ // recipient's client threads it, and so future replies chain back to this conversation.
151
+ const inReplyToId = body.inReplyTo?.trim()
152
+ ? `<${body.inReplyTo.trim().replace(/[<>]/g, '')}>`
153
+ : null
154
+
155
+ const fromEmail = from.replace(/^.*<|>$/g, '').trim()
156
+ const fromName = from.includes('<') ? from.slice(0, from.indexOf('<')).replace(/["']/g, '').trim() : undefined
157
+
158
+ let data: { id: string | null }
159
+ try {
160
+ data = await sendMail({
161
+ from: fromEmail,
162
+ fromName: fromName || undefined,
163
+ to: recipients,
164
+ cc: body.cc,
165
+ bcc: body.bcc,
166
+ replyTo,
167
+ subject: body.subject.trim(),
168
+ html: trackedHtml,
169
+ text: body.text,
170
+ scheduledAt: body.scheduledAt,
171
+ attachments,
172
+ ...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
173
+ })
174
+ } catch (sendError) {
175
+ return NextResponse.json({ ok: false, error: (sendError as Error).message }, { status: 502 })
176
+ }
177
+
178
+ await Promise.all([
179
+ ...[...recipients, ...(body.cc ?? []), ...(body.bcc ?? [])].map(address => recordContact(address, null).catch(() => {})),
180
+ trackedHtml && data?.id
181
+ ? recordPixel(pixelId, data.id, recipients[0] ?? '', body.subject.trim()).catch(() => {})
182
+ : Promise.resolve(),
183
+ data?.id ? recordSentMeta(data.id, account.address, false, body.inReplyTo ?? null).catch(() => {}) : Promise.resolve(),
184
+ data?.id
185
+ ? recordSentMessage({
186
+ id: data.id,
187
+ from,
188
+ to: recipients,
189
+ cc: body.cc ?? [],
190
+ bcc: body.bcc ?? [],
191
+ replyTo: replyTo ? [replyTo] : [],
192
+ subject: body.subject!.trim(),
193
+ html: trackedHtml,
194
+ text: body.text ?? null,
195
+ createdAt: new Date().toISOString(),
196
+ // Recorded so the Sent folder and a forward read our own bucket rather than
197
+ // the provider's, which keeps these only as long as it chooses to.
198
+ attachments: rawAttachments
199
+ .filter(entry => entry.key)
200
+ .map(entry => ({ filename: entry.filename, key: entry.key })),
201
+ }).catch(() => {})
202
+ : Promise.resolve(),
203
+ ])
204
+ return NextResponse.json({ ok: true, id: data?.id, scheduledAt: body.scheduledAt ?? null })
205
+ }