@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,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
|
+
}
|
package/app/globals.css
ADDED
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
|
+
}
|