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