@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,35 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { getSettings, setSettings } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ /**
8
+ * The authenticated identity, not the x-dev-email header: once a session cookie can carry
9
+ * the credential, the header is just a caller-supplied string and would let one signed-in
10
+ * accessor read and overwrite another's settings.
11
+ */
12
+ async function ownerOf(req: Request): Promise<string> {
13
+ const account = await resolveAccount(req)
14
+ return account.email || 'local@dev'
15
+ }
16
+
17
+ export async function GET(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+ const settings = await getSettings(await ownerOf(req))
21
+ return NextResponse.json({ ok: true, settings })
22
+ }
23
+
24
+ export async function PUT(req: Request) {
25
+ const guard = await mailAuthGuard(req)
26
+ if (guard) return guard
27
+ let body: Record<string, unknown>
28
+ try {
29
+ body = await req.json()
30
+ } catch {
31
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
32
+ }
33
+ await setSettings(await ownerOf(req), body)
34
+ return NextResponse.json({ ok: true })
35
+ }
@@ -0,0 +1,91 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { createShare, getInboundSource } from '@/lib/mailbox'
5
+ import { hashPassword } from '@/lib/password'
6
+ import { presign } from '@/lib/r2'
7
+
8
+ export const runtime = 'nodejs'
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ /**
12
+ * Only our own stored copies are ever copied out. The client names a message and a
13
+ * filename; the URL comes from the row, never from the request.
14
+ */
15
+ const OWN_BLOB_HOST = /\.public\.blob\.vercel-storage\.com$/
16
+
17
+ export async function POST(req: Request) {
18
+ const guard = await mailAuthGuard(req)
19
+ if (guard) return guard
20
+ const account = await resolveAccount(req)
21
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
22
+
23
+ let body: { messageId?: string; filename?: string; password?: string; expiresInDays?: number; maxDownloads?: number }
24
+ try {
25
+ body = await req.json()
26
+ } catch {
27
+ return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
28
+ }
29
+
30
+ const messageId = String(body.messageId ?? '')
31
+ const filename = String(body.filename ?? '')
32
+ if (!messageId || !filename) return NextResponse.json({ ok: false, error: 'Which attachment?' }, { status: 400 })
33
+
34
+ const source = await getInboundSource(messageId)
35
+ if (!source) return NextResponse.json({ ok: false, error: 'That message is not here.' }, { status: 404 })
36
+ const address = (account.address ?? '').toLowerCase()
37
+ const mine = Boolean(source.owner && address && source.owner.toLowerCase() === address)
38
+ // Sharing publishes a file to anyone with the link, so it is limited to the mailbox
39
+ // that received it — administering accounts does not extend to another person's files.
40
+ if (!mine) {
41
+ return NextResponse.json({ ok: false, error: 'That message is not yours to share.' }, { status: 403 })
42
+ }
43
+
44
+ const entry = source.attachments.find(item => String(item.filename ?? '') === filename && typeof item.url === 'string')
45
+ let origin: URL | null = null
46
+ try {
47
+ origin = entry ? new URL(String(entry.url)) : null
48
+ } catch {
49
+ origin = null
50
+ }
51
+ if (!entry || !origin || !OWN_BLOB_HOST.test(origin.hostname)) {
52
+ return NextResponse.json({ ok: false, error: 'That attachment has no stored copy to share.' }, { status: 404 })
53
+ }
54
+
55
+ const upstream = await fetch(origin.toString())
56
+ if (!upstream.ok) return NextResponse.json({ ok: false, error: 'The stored copy could not be read.' }, { status: 502 })
57
+ const bytes = Buffer.from(await upstream.arrayBuffer())
58
+ const contentType = String(entry.contentType ?? upstream.headers.get('content-type') ?? 'application/octet-stream')
59
+
60
+ const id = randomBytes(9).toString('base64url')
61
+ const safe = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
62
+ const objectKey = `shares/${randomBytes(16).toString('hex')}/${safe}`
63
+
64
+ const put = await fetch(presign(objectKey, 'PUT', 300), {
65
+ method: 'PUT',
66
+ body: bytes,
67
+ headers: { 'content-type': contentType },
68
+ })
69
+ if (!put.ok) {
70
+ return NextResponse.json({ ok: false, error: 'Could not copy the file into share storage.' }, { status: 502 })
71
+ }
72
+
73
+ const expiresAt =
74
+ body.expiresInDays && body.expiresInDays > 0
75
+ ? new Date(Date.now() + body.expiresInDays * 86400_000).toISOString()
76
+ : null
77
+ await createShare({
78
+ id,
79
+ objectKey,
80
+ filename,
81
+ contentType,
82
+ size: bytes.length,
83
+ passwordHash: body.password ? await hashPassword(body.password) : null,
84
+ owner: account.email,
85
+ expiresAt,
86
+ maxDownloads: body.maxDownloads && body.maxDownloads > 0 ? body.maxDownloads : null,
87
+ })
88
+
89
+ const base = process.env.MAIL_PUBLIC_URL?.replace(/\/$/, '') || new URL(req.url).origin
90
+ return NextResponse.json({ ok: true, id, url: `${base}/share/${id}`, expiresAt })
91
+ }
@@ -0,0 +1,130 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { NextResponse } from 'next/server'
3
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
4
+ import { createShare, getShare, listShares, revokeShare, setSharePassword } from '@/lib/mailbox'
5
+ import { hashPassword } from '@/lib/password'
6
+ import { deleteObject, presign } from '@/lib/r2'
7
+
8
+ export const runtime = 'nodejs'
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ /** Large enough for video, small enough that a mistake is not a bill. */
12
+ const MAX_SHARE_BYTES = 2 * 1024 * 1024 * 1024
13
+
14
+ /** Keeps the stored key opaque, so the object name leaks nothing about the sender. */
15
+ function objectKeyFor(filename: string): string {
16
+ const safe = filename.replace(/[^\w.\- ]+/g, '_').slice(-120)
17
+ return `shares/${randomBytes(16).toString('hex')}/${safe}`
18
+ }
19
+
20
+ export async function GET(req: Request) {
21
+ const guard = await mailAuthGuard(req)
22
+ if (guard) return guard
23
+ const account = await resolveAccount(req)
24
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
25
+ return NextResponse.json({ ok: true, shares: await listShares(account.email) })
26
+ }
27
+
28
+ /**
29
+ * Registers a file and hands back a URL the browser can PUT straight to. The bytes
30
+ * never pass through this server, so there is no request-body ceiling and a two
31
+ * gigabyte upload costs us nothing but the signature.
32
+ */
33
+ export async function POST(req: Request) {
34
+ const guard = await mailAuthGuard(req)
35
+ if (guard) return guard
36
+ const account = await resolveAccount(req)
37
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
38
+
39
+ let body: {
40
+ filename?: string
41
+ contentType?: string
42
+ size?: number
43
+ password?: string
44
+ expiresInDays?: number
45
+ maxDownloads?: number
46
+ }
47
+ try {
48
+ body = await req.json()
49
+ } catch {
50
+ return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
51
+ }
52
+
53
+ const filename = String(body.filename ?? '').trim()
54
+ const size = Number(body.size ?? 0)
55
+ if (!filename) return NextResponse.json({ ok: false, error: 'A filename is required.' }, { status: 400 })
56
+ if (!Number.isFinite(size) || size <= 0) {
57
+ return NextResponse.json({ ok: false, error: 'A file size is required.' }, { status: 400 })
58
+ }
59
+ if (size > MAX_SHARE_BYTES) {
60
+ return NextResponse.json({ ok: false, error: 'That file is larger than 2GB.' }, { status: 413 })
61
+ }
62
+
63
+ const id = randomBytes(9).toString('base64url')
64
+ const objectKey = objectKeyFor(filename)
65
+ const expiresAt =
66
+ body.expiresInDays && body.expiresInDays > 0
67
+ ? new Date(Date.now() + body.expiresInDays * 86400_000).toISOString()
68
+ : null
69
+
70
+ try {
71
+ await createShare({
72
+ id,
73
+ objectKey,
74
+ filename,
75
+ contentType: body.contentType ?? null,
76
+ size,
77
+ passwordHash: body.password ? await hashPassword(body.password) : null,
78
+ owner: account.email,
79
+ expiresAt,
80
+ maxDownloads: body.maxDownloads && body.maxDownloads > 0 ? body.maxDownloads : null,
81
+ })
82
+ // An hour is plenty for the upload itself and short enough that a leaked URL
83
+ // is not a standing grant.
84
+ const uploadUrl = presign(objectKey, 'PUT', 3600)
85
+ return NextResponse.json({ ok: true, id, uploadUrl, objectKey })
86
+ } catch (err) {
87
+ return NextResponse.json(
88
+ { ok: false, error: err instanceof Error ? err.message : 'Could not prepare the upload.' },
89
+ { status: 500 },
90
+ )
91
+ }
92
+ }
93
+
94
+ /** Sets or clears the password on a share the caller owns. */
95
+ export async function PATCH(req: Request) {
96
+ const guard = await mailAuthGuard(req)
97
+ if (guard) return guard
98
+ const account = await resolveAccount(req)
99
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
100
+
101
+ let body: { id?: string; password?: string }
102
+ try {
103
+ body = await req.json()
104
+ } catch {
105
+ return NextResponse.json({ ok: false, error: 'Invalid request.' }, { status: 400 })
106
+ }
107
+
108
+ const id = String(body.id ?? '')
109
+ if (!id) return NextResponse.json({ ok: false, error: 'Which share?' }, { status: 400 })
110
+
111
+ const password = String(body.password ?? '').trim()
112
+ const updated = await setSharePassword(id, account.email, password ? await hashPassword(password) : null)
113
+ return NextResponse.json({ ok: updated }, { status: updated ? 200 : 404 })
114
+ }
115
+
116
+ export async function DELETE(req: Request) {
117
+ const guard = await mailAuthGuard(req)
118
+ if (guard) return guard
119
+ const account = await resolveAccount(req)
120
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
121
+
122
+ const id = new URL(req.url).searchParams.get('id')
123
+ if (!id) return NextResponse.json({ ok: false, error: 'Which share?' }, { status: 400 })
124
+ // Flag first so the link dies even if storage is unreachable; then drop the bytes,
125
+ // because a revoked file that still sits in the bucket is not revoked.
126
+ const share = await getShare(id)
127
+ const revoked = await revokeShare(id, account.email)
128
+ if (revoked && share?.objectKey) await deleteObject(share.objectKey).catch(() => false)
129
+ return NextResponse.json({ ok: revoked }, { status: revoked ? 200 : 404 })
130
+ }
@@ -0,0 +1,67 @@
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 MAX_BYTES = 2 * 1024 * 1024
9
+ const TYPES: Record<string, string> = {
10
+ 'image/png': 'png',
11
+ 'image/jpeg': 'jpg',
12
+ 'image/gif': 'gif',
13
+ 'image/webp': 'webp',
14
+ 'image/svg+xml': 'svg',
15
+ }
16
+
17
+ /**
18
+ * A signature logo has to stay reachable for whoever opens the mail, months later, so it
19
+ * cannot be a presigned link that expires or an attachment. The bytes live in the bucket
20
+ * and this route streams them back under a stable, unauthenticated URL — the same one
21
+ * embedded in every message that carries the signature.
22
+ */
23
+ export async function GET(req: Request) {
24
+ const key = new URL(req.url).searchParams.get('key')
25
+ if (!key || !/^signatures\/[A-Za-z0-9._-]+$/.test(key)) {
26
+ return NextResponse.json({ ok: false, error: 'Bad key' }, { status: 400 })
27
+ }
28
+ const upstream = await fetch(presign(key, 'GET', 300)).catch(() => null)
29
+ if (!upstream?.ok) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
30
+ return new NextResponse(upstream.body, {
31
+ headers: {
32
+ 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
33
+ 'cache-control': 'public, max-age=31536000, immutable',
34
+ },
35
+ })
36
+ }
37
+
38
+ export async function POST(req: Request) {
39
+ const guard = await mailAuthGuard(req)
40
+ if (guard) return guard
41
+ const account = await resolveAccount(req)
42
+ if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
43
+
44
+ const contentType = (req.headers.get('content-type') ?? '').split(';')[0].trim()
45
+ const extension = TYPES[contentType]
46
+ if (!extension) {
47
+ return NextResponse.json({ ok: false, error: 'Use a PNG, JPEG, GIF, WebP or SVG image.' }, { status: 400 })
48
+ }
49
+
50
+ const bytes = Buffer.from(await req.arrayBuffer())
51
+ if (bytes.length === 0) return NextResponse.json({ ok: false, error: 'That file is empty.' }, { status: 400 })
52
+ if (bytes.length > MAX_BYTES) {
53
+ return NextResponse.json({ ok: false, error: 'Keep the logo under 2MB.' }, { status: 413 })
54
+ }
55
+
56
+ // Content-addressed, so re-uploading the same logo does not pile up copies.
57
+ const digest = createHash('sha256').update(bytes).digest('hex').slice(0, 24)
58
+ const key = `signatures/${digest}.${extension}`
59
+ const put = await fetch(presign(key, 'PUT', 300), {
60
+ method: 'PUT',
61
+ headers: { 'content-type': contentType },
62
+ body: bytes,
63
+ }).catch(() => null)
64
+ if (!put?.ok) return NextResponse.json({ ok: false, error: 'Could not store that image.' }, { status: 502 })
65
+
66
+ return NextResponse.json({ ok: true, url: `/api/mail/signature-logo?key=${encodeURIComponent(key)}` })
67
+ }
@@ -0,0 +1,55 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { deleteStash, readStash, upsertStash } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ const KINDS = new Set(['draft', 'template'])
8
+
9
+ /**
10
+ * The authenticated identity, not the x-dev-email header: once a session cookie can carry
11
+ * the credential, the header is just a caller-supplied string and would let one signed-in
12
+ * accessor read and overwrite another's drafts.
13
+ */
14
+ async function ownerOf(req: Request): Promise<string> {
15
+ const account = await resolveAccount(req)
16
+ return account.email || 'local@dev'
17
+ }
18
+
19
+ export async function GET(req: Request) {
20
+ const guard = await mailAuthGuard(req)
21
+ if (guard) return guard
22
+ const kind = new URL(req.url).searchParams.get('kind') ?? ''
23
+ if (!KINDS.has(kind)) return NextResponse.json({ ok: false, error: 'Unknown kind' }, { status: 400 })
24
+ const items = await readStash(await ownerOf(req), kind)
25
+ return NextResponse.json({ ok: true, items })
26
+ }
27
+
28
+ export async function PUT(req: Request) {
29
+ const guard = await mailAuthGuard(req)
30
+ if (guard) return guard
31
+ let body: { kind?: string; id?: string; data?: unknown }
32
+ try {
33
+ body = await req.json()
34
+ } catch {
35
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
36
+ }
37
+ if (!body.kind || !KINDS.has(body.kind) || !body.id) {
38
+ return NextResponse.json({ ok: false, error: 'kind and id are required' }, { status: 400 })
39
+ }
40
+ await upsertStash(await ownerOf(req), body.kind, body.id, body.data ?? null)
41
+ return NextResponse.json({ ok: true })
42
+ }
43
+
44
+ export async function DELETE(req: Request) {
45
+ const guard = await mailAuthGuard(req)
46
+ if (guard) return guard
47
+ const params = new URL(req.url).searchParams
48
+ const kind = params.get('kind') ?? ''
49
+ const id = params.get('id') ?? ''
50
+ if (!KINDS.has(kind) || !id) {
51
+ return NextResponse.json({ ok: false, error: 'kind and id are required' }, { status: 400 })
52
+ }
53
+ await deleteStash(await ownerOf(req), kind, id)
54
+ return NextResponse.json({ ok: true })
55
+ }
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { listThreads, threadsLive, type ThreadFolder } 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
+ if (!threadsLive()) return NextResponse.json({ ok: true, threads: [] })
11
+ const account = await resolveAccount(req)
12
+ const url = new URL(req.url)
13
+ const folderParam = url.searchParams.get('folder')
14
+ const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred'] as const).find(f => f === folderParam) ?? 'inbox'
15
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
16
+ const page = await listThreads(account.address ?? ' no-address', folder, limit, url.searchParams.get('cursor'))
17
+ return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
18
+ }
@@ -0,0 +1,43 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
3
+ import { authenticate, isLocalOrigin, verifyMailAuth } from '@/lib/dev-auth'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
8
+
9
+ export async function POST(req: Request): Promise<NextResponse> {
10
+ const body = (await req.json()) as HandleUploadBody
11
+
12
+ try {
13
+ const result = await handleUpload({
14
+ body,
15
+ request: req,
16
+ onBeforeGenerateToken: async (_pathname, clientPayload) => {
17
+ // The session cookie is the normal path; the credentials in clientPayload are only
18
+ // still read so a tab that signed in before sessions existed can finish its upload.
19
+ let authorized = isLocalOrigin(req) || Boolean(await authenticate(req))
20
+ if (!authorized && clientPayload) {
21
+ try {
22
+ const creds = JSON.parse(clientPayload) as { email?: string; password?: string }
23
+ authorized = (await verifyMailAuth(creds.email ?? '', creds.password ?? '')).ok
24
+ } catch {
25
+ authorized = false
26
+ }
27
+ }
28
+ if (!authorized) throw new Error('Unauthorized')
29
+ return {
30
+ addRandomSuffix: true,
31
+ maximumSizeInBytes: MAX_UPLOAD_BYTES,
32
+ tokenPayload: null,
33
+ }
34
+ },
35
+ onUploadCompleted: async () => {
36
+ // No-op: the URL is returned to the client, which attaches it on send.
37
+ },
38
+ })
39
+ return NextResponse.json(result)
40
+ } catch (error) {
41
+ return NextResponse.json({ error: (error as Error).message }, { status: 400 })
42
+ }
43
+ }
@@ -0,0 +1,85 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getShare, getSharePasswordHash, recordShareDownload } from '@/lib/mailbox'
3
+ import { verifyPassword } from '@/lib/password'
4
+ import { objectExists, presign } from '@/lib/r2'
5
+ import { clientKey, rateLimit } from '@/lib/rate-limit'
6
+
7
+ export const runtime = 'nodejs'
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ /**
11
+ * Deliberately public: the recipient is not a mailbox holder. The share id is the
12
+ * capability, and the password (when set) is the second factor. Every refusal
13
+ * reads the same so the endpoint cannot be used to sort real ids from invented
14
+ * ones by their error message.
15
+ */
16
+ const GONE = { ok: false as const, error: 'This link is no longer available.' }
17
+
18
+ /** What the page needs to render before anyone types anything. */
19
+ export async function GET(_req: Request, context: { params: Promise<{ id: string }> }) {
20
+ const { id } = await context.params
21
+ const share = await getShare(id)
22
+ if (!share || share.revoked) return NextResponse.json(GONE, { status: 404 })
23
+ if (share.expiresAt && new Date(share.expiresAt) < new Date()) {
24
+ return NextResponse.json(GONE, { status: 410 })
25
+ }
26
+ if (share.maxDownloads != null && share.downloads >= share.maxDownloads) {
27
+ return NextResponse.json(GONE, { status: 410 })
28
+ }
29
+ return NextResponse.json({
30
+ ok: true,
31
+ filename: share.filename,
32
+ size: share.size,
33
+ contentType: share.contentType,
34
+ needsPassword: share.hasPassword,
35
+ expiresAt: share.expiresAt,
36
+ downloadsLeft: share.maxDownloads == null ? null : share.maxDownloads - share.downloads,
37
+ })
38
+ }
39
+
40
+ /** Exchanges the password for a short-lived signed URL to the object itself. */
41
+ export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
42
+ const { id } = await context.params
43
+
44
+ // The password is guessable one attempt at a time, so cap the attempts.
45
+ const limited = rateLimit(`${clientKey(req, 'share')}:${id}`, 10, 15 * 60 * 1000)
46
+ if (limited) return limited
47
+
48
+ const share = await getShare(id)
49
+ if (!share || share.revoked) return NextResponse.json(GONE, { status: 404 })
50
+ if (share.expiresAt && new Date(share.expiresAt) < new Date()) {
51
+ return NextResponse.json(GONE, { status: 410 })
52
+ }
53
+ if (share.maxDownloads != null && share.downloads >= share.maxDownloads) {
54
+ return NextResponse.json(GONE, { status: 410 })
55
+ }
56
+
57
+ if (share.hasPassword) {
58
+ let password = ''
59
+ try {
60
+ password = String(((await req.json()) as { password?: string }).password ?? '')
61
+ } catch {
62
+ // Leave it empty and fall through to the same refusal.
63
+ }
64
+ const hash = await getSharePasswordHash(id)
65
+ if (!hash || !(await verifyPassword(password, hash))) {
66
+ return NextResponse.json({ ok: false, error: 'That password is not right.' }, { status: 403 })
67
+ }
68
+ }
69
+
70
+ // Checked before the download is counted, so a missing object does not burn one of
71
+ // the recipient's allowed downloads.
72
+ if (!(await objectExists(share.objectKey))) {
73
+ return NextResponse.json(
74
+ { ok: false, error: 'This file is no longer stored. Ask whoever sent it to upload it again.' },
75
+ { status: 410 },
76
+ )
77
+ }
78
+
79
+ await recordShareDownload(id)
80
+ // Two minutes: long enough to start the download, too short to pass around.
81
+ const url = presign(share.objectKey, 'GET', 120, {
82
+ 'response-content-disposition': `attachment; filename="${share.filename.replace(/"/g, '')}"`,
83
+ })
84
+ return NextResponse.json({ ok: true, url, filename: share.filename })
85
+ }
Binary file
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { extname, join } from 'node:path'
3
+ import { NextResponse } from 'next/server'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ const DIR = process.env.BRAND_ASSET_DIR ?? '/data/brand'
8
+
9
+ const TYPES: Record<string, string> = {
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.svg': 'image/svg+xml',
16
+ '.ico': 'image/x-icon',
17
+ }
18
+
19
+ /**
20
+ * A tenant's own images, read from the volume rather than the repository, so the codebase
21
+ * carries no brand. Public by design: a recipient's mail client fetches the mark in a
22
+ * signature with no session, from wherever in the world they open the message.
23
+ */
24
+ export async function GET(_request: Request, { params }: { params: Promise<{ file: string }> }) {
25
+ const { file } = await params
26
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(file)) return new NextResponse('Not found', { status: 404 })
27
+ const type = TYPES[extname(file).toLowerCase()]
28
+ if (!type) return new NextResponse('Not found', { status: 404 })
29
+ try {
30
+ const bytes = await readFile(join(DIR, file))
31
+ return new NextResponse(new Uint8Array(bytes), {
32
+ headers: { 'content-type': type, 'cache-control': 'public, max-age=86400' },
33
+ })
34
+ } catch {
35
+ return new NextResponse('Not found', { status: 404 })
36
+ }
37
+ }
@@ -0,0 +1,14 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ }
4
+
5
+ * {
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ html,
10
+ body {
11
+ margin: 0;
12
+ padding: 0;
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
14
+ }
package/app/icon.png ADDED
Binary file
package/app/layout.tsx ADDED
@@ -0,0 +1,30 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import type { Metadata, Viewport } from 'next'
3
+ import type { ReactNode } from 'react'
4
+ import './globals.css'
5
+
6
+ export const metadata: Metadata = {
7
+ title: `${BRAND.name} Mail`,
8
+ description: `${BRAND.name} team mailbox`,
9
+ robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
10
+ manifest: '/manifest.webmanifest',
11
+ ...(BRAND.iconUrl
12
+ ? { icons: { icon: BRAND.iconUrl, shortcut: BRAND.iconUrl, apple: BRAND.appleIconUrl || BRAND.iconUrl } }
13
+ : {}),
14
+ applicationName: `${BRAND.name} Mail`,
15
+ appleWebApp: { capable: true, title: `${BRAND.name} Mail`, statusBarStyle: 'black-translucent' },
16
+ formatDetection: { telephone: false },
17
+ }
18
+
19
+ export const viewport: Viewport = {
20
+ themeColor: BRAND.colors.accent,
21
+ viewportFit: 'cover',
22
+ }
23
+
24
+ export default function RootLayout({ children }: { children: ReactNode }) {
25
+ return (
26
+ <html lang="en">
27
+ <body>{children}</body>
28
+ </html>
29
+ )
30
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import styles from './page.module.css'
5
+
6
+ const VERBS = [
7
+ 'Waking the mailbox',
8
+ 'Checking your access',
9
+ 'Verifying credentials',
10
+ 'Opening the vault',
11
+ 'Counting what arrived',
12
+ 'Almost there',
13
+ ]
14
+
15
+ export default function AccessCheck() {
16
+ const [verbIndex, setVerbIndex] = useState(0)
17
+
18
+ useEffect(() => {
19
+ if (verbIndex >= VERBS.length - 1) return
20
+ const timer = setTimeout(() => setVerbIndex(current => current + 1), verbIndex === 0 ? 900 : 1500)
21
+ return () => clearTimeout(timer)
22
+ }, [verbIndex])
23
+
24
+ return (
25
+ <div className={styles.loginWrap}>
26
+ <div className={styles.accessCheck}>
27
+ <div className={styles.accessStage}>
28
+ <span className={styles.accessMark} aria-hidden="true" />
29
+ <svg className={styles.accessRing} viewBox="0 0 100 100" aria-hidden="true">
30
+ <circle className={styles.accessRingTrack} cx="50" cy="50" r="45" />
31
+ <circle className={styles.accessRingArc} cx="50" cy="50" r="45" />
32
+ </svg>
33
+ </div>
34
+ <p className={styles.accessVerb} key={verbIndex} role="status" aria-live="polite">
35
+ {VERBS[verbIndex]}
36
+ <span className={styles.accessDots} aria-hidden="true">
37
+ <i />
38
+ <i />
39
+ <i />
40
+ </span>
41
+ </p>
42
+ </div>
43
+ </div>
44
+ )
45
+ }