@novacraft-engineering/mailbox 0.1.0 → 0.2.1
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/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +23 -4
- package/app/mail/RichEditor.tsx +167 -53
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +643 -76
- package/app/mail/page.tsx +1062 -247
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { claimWebhookEvent, completeWebhookEvent, releaseWebhookEvent } from '@/lib/mailbox'
|
|
4
|
+
import { findMissingReceived, ingestReceived } from '@/lib/receive'
|
|
5
|
+
|
|
6
|
+
export const runtime = 'nodejs'
|
|
7
|
+
export const maxDuration = 300
|
|
8
|
+
|
|
9
|
+
function authorised(req: Request): boolean {
|
|
10
|
+
const secret = process.env.MAINTENANCE_TOKEN
|
|
11
|
+
if (!secret) return false
|
|
12
|
+
const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
|
|
13
|
+
if (token.length !== secret.length) return false
|
|
14
|
+
try {
|
|
15
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(secret))
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sinceFrom(req: Request): Date {
|
|
22
|
+
const days = Math.min(Math.max(Number(new URL(req.url).searchParams.get('days') ?? 30) || 30, 1), 90)
|
|
23
|
+
return new Date(Date.now() - days * 86400_000)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the provider received for this domain that never reached the mailbox. Read-only. */
|
|
27
|
+
export async function GET(req: Request) {
|
|
28
|
+
if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
|
|
29
|
+
const apiKey = process.env.RESEND_API_KEY
|
|
30
|
+
if (!apiKey) return NextResponse.json({ ok: false, error: 'No provider key' }, { status: 400 })
|
|
31
|
+
const missing = await findMissingReceived(apiKey, sinceFrom(req))
|
|
32
|
+
return NextResponse.json({ ok: true, missing })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Take in the messages the provider holds and we do not — one by `?id=`, or every one
|
|
37
|
+
* since `?days=`. Each goes through the same path a webhook delivery does, under the same
|
|
38
|
+
* kind of claim, so a delivery arriving at the same moment cannot store a second copy.
|
|
39
|
+
*/
|
|
40
|
+
export async function POST(req: Request) {
|
|
41
|
+
if (!authorised(req)) return NextResponse.json({ ok: false, error: 'Unauthorised' }, { status: 403 })
|
|
42
|
+
const apiKey = process.env.RESEND_API_KEY
|
|
43
|
+
if (!apiKey) return NextResponse.json({ ok: false, error: 'No provider key' }, { status: 400 })
|
|
44
|
+
const url = new URL(req.url)
|
|
45
|
+
const one = url.searchParams.get('id')
|
|
46
|
+
const targets = one ? [one] : (await findMissingReceived(apiKey, sinceFrom(req))).map(item => item.id)
|
|
47
|
+
|
|
48
|
+
const taken: Array<{ id: string; owner: string; subject: string }> = []
|
|
49
|
+
const skipped: string[] = []
|
|
50
|
+
const failed: Array<{ id: string; error: string }> = []
|
|
51
|
+
for (const id of targets) {
|
|
52
|
+
const claim = await claimWebhookEvent(`received:${id}`)
|
|
53
|
+
if (claim !== 'claimed') { skipped.push(id); continue }
|
|
54
|
+
try {
|
|
55
|
+
const result = await ingestReceived(id)
|
|
56
|
+
await completeWebhookEvent(`received:${id}`)
|
|
57
|
+
taken.push({ id, owner: result.owner, subject: result.subject })
|
|
58
|
+
} catch (err) {
|
|
59
|
+
await releaseWebhookEvent(`received:${id}`)
|
|
60
|
+
failed.push({ id, error: err instanceof Error ? err.message : String(err) })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return NextResponse.json({ ok: true, taken, skipped, failed })
|
|
64
|
+
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { markPixelOpened } from '@/lib/mailbox'
|
|
2
|
+
import { readSession } from '@/lib/session'
|
|
2
3
|
|
|
3
4
|
export const runtime = 'nodejs'
|
|
4
5
|
export const dynamic = 'force-dynamic'
|
|
5
6
|
|
|
6
7
|
const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/** A signed-in user of this mailbox looking at their own mail is not a recipient opening it. */
|
|
10
|
+
function ownView(req: Request): boolean {
|
|
11
|
+
return Boolean(readSession(req))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
15
|
const { id } = await params
|
|
10
|
-
if (id) await markPixelOpened(id).catch(() => {})
|
|
16
|
+
if (id && !ownView(req)) await markPixelOpened(id).catch(() => {})
|
|
11
17
|
return new Response(PIXEL, {
|
|
12
18
|
headers: {
|
|
13
19
|
'Content-Type': 'image/gif',
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { NextResponse } from 'next/server'
|
|
4
|
-
import { Resend } from 'resend'
|
|
5
4
|
import { isMailAccount } from '@/lib/dev-auth'
|
|
6
5
|
import { createResetToken, recordSentMeta } from '@/lib/mailbox'
|
|
6
|
+
import { sendMail } from '@/lib/mail-provider'
|
|
7
|
+
import { renderActionEmail } from '@/lib/emails'
|
|
7
8
|
import { publicOrigin } from '@/lib/public-url'
|
|
8
9
|
import { clientKey, rateLimit } from '@/lib/rate-limit'
|
|
9
10
|
|
|
@@ -35,30 +36,33 @@ export async function POST(req: Request) {
|
|
|
35
36
|
const token = randomBytes(32).toString('hex')
|
|
36
37
|
await createResetToken(email, token, Date.now() + RESET_TTL_MS)
|
|
37
38
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
39
|
+
const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail)
|
|
40
|
+
.replace(/^.*<|>$/g, '')
|
|
41
|
+
.trim()
|
|
42
|
+
const resetUrl = `${publicOrigin(req)}/mail/reset?token=${token}`
|
|
43
|
+
try {
|
|
44
|
+
const { id } = await sendMail({
|
|
45
|
+
from,
|
|
46
|
+
fromName: `${BRAND.name} Mail`,
|
|
47
|
+
to: [email],
|
|
48
|
+
subject: `Reset your ${BRAND.name} Mail password`,
|
|
49
|
+
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.`,
|
|
50
|
+
html: renderActionEmail({
|
|
51
|
+
eyebrow: `${BRAND.name} · Mail`,
|
|
52
|
+
accent: BRAND.colors.accent,
|
|
53
|
+
title: 'Reset your password',
|
|
54
|
+
body: `Someone asked to reset the password for your ${BRAND.name} Mail account. Choose a new one below.`,
|
|
55
|
+
actionLabel: 'Set a new password',
|
|
56
|
+
actionUrl: resetUrl,
|
|
57
|
+
expiry: 'This link expires in 30 minutes and can be used once.',
|
|
58
|
+
footer: "Didn't request this? Ignore this email — your password will not change.",
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
if (id) await recordSentMeta(id, null, true).catch(() => {})
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// A reset that silently sends nothing is indistinguishable from one that worked.
|
|
64
|
+
console.error('[mail] reset email failed:', err)
|
|
65
|
+
return NextResponse.json({ ok: false, error: 'Could not send the reset email' }, { status: 502 })
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
return NextResponse.json({ ok: true })
|
|
@@ -5,7 +5,7 @@ import { presign } from '@/lib/r2'
|
|
|
5
5
|
|
|
6
6
|
export const runtime = 'nodejs'
|
|
7
7
|
|
|
8
|
-
const MAX_BYTES =
|
|
8
|
+
const MAX_BYTES = 8 * 1024 * 1024
|
|
9
9
|
const TYPES: Record<string, string> = {
|
|
10
10
|
'image/png': 'png',
|
|
11
11
|
'image/jpeg': 'jpg',
|
|
@@ -14,6 +14,22 @@ const TYPES: Record<string, string> = {
|
|
|
14
14
|
'image/svg+xml': 'svg',
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The type the bytes say they are; a browser's label for a file is often missing or wrong. */
|
|
18
|
+
function sniffImageType(bytes: Buffer): string | null {
|
|
19
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
|
|
20
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
21
|
+
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('latin1').startsWith('GIF8')) return 'image/gif'
|
|
22
|
+
if (bytes.length >= 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
23
|
+
if (bytes.length >= 12 && bytes.subarray(4, 8).toString('latin1') === 'ftyp') {
|
|
24
|
+
const brand = bytes.subarray(8, 12).toString('latin1')
|
|
25
|
+
if (/^(heic|heix|hevc|mif1|msf1)/.test(brand)) return 'image/heic'
|
|
26
|
+
if (/^avi[fs]/.test(brand)) return 'image/avif'
|
|
27
|
+
}
|
|
28
|
+
const head = bytes.subarray(0, 512).toString('utf8').trimStart().toLowerCase()
|
|
29
|
+
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) return 'image/svg+xml'
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
/**
|
|
18
34
|
* A signature logo has to stay reachable for whoever opens the mail, months later, so it
|
|
19
35
|
* cannot be a presigned link that expires or an attachment. The bytes live in the bucket
|
|
@@ -41,16 +57,25 @@ export async function POST(req: Request) {
|
|
|
41
57
|
const account = await resolveAccount(req)
|
|
42
58
|
if (!account.email) return NextResponse.json({ ok: false, error: 'Not signed in.' }, { status: 401 })
|
|
43
59
|
|
|
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
60
|
const bytes = Buffer.from(await req.arrayBuffer())
|
|
51
61
|
if (bytes.length === 0) return NextResponse.json({ ok: false, error: 'That file is empty.' }, { status: 400 })
|
|
52
62
|
if (bytes.length > MAX_BYTES) {
|
|
53
|
-
return NextResponse.json({ ok: false, error: 'Keep the
|
|
63
|
+
return NextResponse.json({ ok: false, error: 'Keep the image under 8MB.' }, { status: 413 })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const declared = (req.headers.get('content-type') ?? '').split(/[;,]/)[0].trim().toLowerCase()
|
|
67
|
+
const sniffed = sniffImageType(bytes)
|
|
68
|
+
// Raster formats always announce themselves in their first bytes; only SVG is taken on the label.
|
|
69
|
+
const contentType = sniffed && TYPES[sniffed] ? sniffed : !sniffed && declared === 'image/svg+xml' ? declared : ''
|
|
70
|
+
const extension = TYPES[contentType]
|
|
71
|
+
if (!extension) {
|
|
72
|
+
if (sniffed === 'image/heic' || sniffed === 'image/avif') {
|
|
73
|
+
return NextResponse.json(
|
|
74
|
+
{ ok: false, error: 'That is a HEIC/AVIF photo, which mail apps cannot display. Export it as JPEG or PNG first.' },
|
|
75
|
+
{ status: 400 },
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return NextResponse.json({ ok: false, error: 'Use a PNG, JPEG, GIF, WebP or SVG image.' }, { status: 400 })
|
|
54
79
|
}
|
|
55
80
|
|
|
56
81
|
// Content-addressed, so re-uploading the same logo does not pile up copies.
|
|
@@ -11,7 +11,7 @@ export async function GET(req: Request) {
|
|
|
11
11
|
const account = await resolveAccount(req)
|
|
12
12
|
const url = new URL(req.url)
|
|
13
13
|
const folderParam = url.searchParams.get('folder')
|
|
14
|
-
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
14
|
+
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
15
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
16
16
|
const page = await listThreads(account.address ?? ' no-address', folder, limit, url.searchParams.get('cursor'))
|
|
17
17
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const PLACEHOLDER_MARK = Buffer.from(
|
|
2
|
+
'iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAATMklEQVR42u3df6xedX0H8HcHJcOfWBPqwg+FaM7BzSiDNSxIW0Sm' +
|
|
3
|
+
'FA0/QlsIM1uNv1Y4ZjiSymjDAlRBh2JOqwnGliVEXBEBF8piMKPgdAxrNGHhHLMFQWJIjOKPRCMO2B/3cSHYXvrj3ud+n/O8XgnB' +
|
|
4
|
+
'P+Te7/1873M+73vO9/M8CQAAAAAAAAAAAAAAQBGaulcEAPQOAAAAAAAAAAAAgDiZCQBJU/eLVAEAAAAAAAAAAAAAAAAAAAAAAAAA' +
|
|
5
|
+
'AAAAAAAAiE/xAwC90g8EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv6epe0UAAL0ZAAAAAAAAAAAAAAAAAAAAAAAA' +
|
|
6
|
+
'AAAAyAG9r/AiVQAAnxEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADxecEAAAAAAAAAAAAAAAAAQEz/AQAAAAAAAAAAAAAA' +
|
|
7
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
|
|
8
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNikRJMt6buz0lyXJKXJTkiyRJVgcH6aZKfJflF' +
|
|
9
|
+
'ksfarrpLSQQApqPZr06yJskFqgG8wJeTfKntqtuVQgBgGE3/7Uk2JlmhGsA+6pJc03bVF5VCAGDyGv99mj4wBx5ou2q5MggAlN/4' +
|
|
10
|
+
'NyS5TiWAOfbRtquuVwYBgPIa/7Ik30xyiGoA8+S3SU5tu+ohpZh8f6AEg2j+G5M8qPkD82xxkv9s6n6LUrgDwMI3/8eTHKMSwJg9' +
|
|
11
|
+
'2nbV8cogALAwzf85VQAWUttV+kg8AkDzB1yLEADwggNckxAA8EIDXJsQADigF9iTqgAUfI16QhUEAOb+hbUlyVKVAAp2VFP3NyhD' +
|
|
12
|
+
'TAEwZ81/eZJdKgFMiFPbrvqmMrgDwMHT/IFJ8u9KIABw8H/9X6sKwAReuzapggDAwblSCYAJdLUSCAAceIJ2mAaY5GuYTyYVADhA' +
|
|
13
|
+
'H1ECYIJtUAIBgP1Pzh9QBWAA17J1qiAAEM/+ganjMKAAwH46VgmAAThOCeKNgMi+3jL7mySfnbBl77ZzMDZ/OmHX7w+0XfV521aW' +
|
|
14
|
+
'Q5WgSGsncM0vbbvqBFsH8/4Hwncm8I+3tUkEgHgEwItbMYFrrn0aGIzlE/dOnMCln2H3BACm4ALV1P16lYC5PUkvYCMADP+F/mcD' +
|
|
15
|
+
'+DG2NnV/t92EObkmbEuybQA/x0l2UwBgdkN5kaxq6v7nthMOqmk+mWTdgA4uEocA2bujB/SzvGJ02/Kstqv+1dbCVH8E+DF21h0A' +
|
|
16
|
+
'ZrdkgD/TPU3df8zWwj41/ysH+hHgS+yuAMDsjhjoz3VFU/eP2F6Ytfnfl+Raf9wgAEynwwf8sxkVhNlH/FbEtY04A8CwL3R/3XbV' +
|
|
17
|
+
'P6kGXg/96iQ7VAJ3AJgWNxsVRPPvt2n+CABMo1VN3T+lDExp8/9RhjPiRzwCoGx3JumSfLSgNR0xeiSwvO2qB2wRU9D4lyV5sMCl' +
|
|
18
|
+
'bU/yqiTn2iV3ABigtquuKPQFfn9T91fbIQbe/DcU2vwvarvqvXbIHQCGHwLuSrKoqfunkywuaGmbmrpf1XbVyXaJATb/u5OsKvB6' +
|
|
19
|
+
'4GPh3QFgCoPAYUl2Frask4wKMtDJl9Ka/27NXwBgukPA2UnWF/qpghfbISa88Z9XaKC93J02BADSdtXnkpR4MbilqXsjUkxq89+S' +
|
|
20
|
+
'5CsFLu3UtqtusEM4A8DvQsDuzJwLeCzJsQUtbXVT9z9pu+rVdokJav6lvY6S5MdtVx1pd3AHgL0Fgdcmua6wZS0ZPRJ4qx2i9M+8' +
|
|
21
|
+
'H93yL635b9f8EQDY11HB8wtc2gNN3V9uhyi0+a9P8u0Cl3axET/iEQD7EQLuyMwjgdIOMH2yqfsLHWDCiJ8RP9wBYP4vIEYFYe/N' +
|
|
22
|
+
'/2kjfggADHlUcEOho4IX2iEW6PfvnFEQXVzY0q52hwwBgLkMAZ9IsqzApd06+kQ1GGfz/3hmPlujNMvbrrrKDhFnAJjjEPBQZs4F' +
|
|
23
|
+
'PJHkqIKWtq6p+7PbrlpqlxhD8380yesKW9ZTbVctsTu4A8B8B4Gjk2wtbFlHjh4JnGKHmOcRv9Ka/22aPwIA4wwBlya5oMClfaup' +
|
|
24
|
+
'+8vsEHPc/D+UMkf83tN21Ro7RDwCYMwh4PaUOSr4qabuz2m7aqVdYg6a/44kq4344Q4A7PlCtKuwZa0wKsgcNP/fFNj8O80fAYCS' +
|
|
25
|
+
'QsDKJBsLHRV8tx1iP39vzhwFyMMKW9rmtqtOsEMIAJQWAjYnWVHg0r5qVJD9HPH7WoFLe0fbVRvtEHEGgEJDwP2ZORfwZJKlhY0K' +
|
|
26
|
+
'ntZ21RvsErM0/+8meXNhy/pF21WvtDu4A8CkBIHXJNle2LJeP3oksMwOsafHRQU2/52aPwIAkxgC3ptkbYFLe7Cp+w/bIUaN//2F' +
|
|
27
|
+
'HhhdN3obbhAAmMgQsKPQE8ufGX2CG9Pd/LcluanEyZq2q262QwgADGVUcHdhy1rV1P0zdmdqm/+vkqyLET8EAJj3EHBykk2lvQZG' +
|
|
28
|
+
'5wLOskNT0/jfNrrlf3hhS9tqxA8BgCGHgGuTnF7g0nY2db/FDg2++W9M8vUCl3bW6O21IcYAGXIIuC8zo4K/TPKygpZ2SVP3Z/gr' +
|
|
29
|
+
'bLDN/ztJTixsWc+2XXWI3cEdAKYtCLw85Y0K1t5CeLAjficWOOKn+SMAMNWjgu8r9C2E19uhiW/86woNdI0RPwQAhICu+kKhJ5+3' +
|
|
30
|
+
'GhWc+BG/bYWO+DlvggAALxgV/F7KGxX8ud2ZuOb/ZMob8fu+ET8EANh7CHhLkusKW9YrRo8E3mmHim/8y0e3/JcWtrTtbVdVdggB' +
|
|
31
|
+
'AGYPAVckObPApd3T1P3H7FCxzf/KJLsKXNq7RmddIMYA4cVDwL2ZGRX8dZI/LGhpVzR1f55RweKa/30p8KOo3fLHHQA48Avo4Ulu' +
|
|
32
|
+
'i1FBZh/xK63579L8EQDg4EPAmiQfLHRU8K/s0ILVf3WhQeyytqtW2iEEAJibEHBToX9R3WxUcMFG/HYUuLRT2q660Q4hAMD8PFN9' +
|
|
33
|
+
'OOWNCj5ld8bW/H+U8kb8Hh3N9z9ohxAAYP5CwJtS3qjgEaNHAqfZoXlr/MtGt/z/KOWN+B1vhxAAYHyjgucWuLT7m7q/2g7NefPf' +
|
|
34
|
+
'kKTEv64vMuJHjAHC2EPAXZkZFXw6yeKClrapqftVbVedbJfmpPnfnWSVET9wBwBeeCE+LMnOwpZ1klHBORvxK63579b8EQCgnBBw' +
|
|
35
|
+
'dpL1hY4KXmyH9rtu5xUaoC53ZwcBAMoLAZ9LUuLF+Zam7nfYoX1u/luSfKXApZ3adtUNdog4AwBFhoDdmTkX8FiSYwta2uqm7n/S' +
|
|
36
|
+
'dtWr7dKszb+0fUuSH7dddaTdwR0AmIwg8NqUNyq4ZPRI4K126Pca/+/OTBxb4Iif5o8AABM4Knh+gUt7oKn7y+3Q/zf/9Um+XeDS' +
|
|
37
|
+
'LjbiRzwCgIkNAXdk5pFAaQfKPtnU/YXTfqDMiB+4AwDjuKAbFSyr+T9txA8EABjXqOCGQkcFL5yixn/OKPgsLmxpVxvxQwCA4YaA' +
|
|
38
|
+
'TyRZVuDSbh19wt3Qm//Hk9xZ4NKWt111lVcIcQYABh0CHsrMuYAnkhxV0NLWNXV/dttVSwfa/B9N8rrClvVU21VLvCpwBwCmKwgc' +
|
|
39
|
+
'nWRrYcs6cvRI4JQBjviV1vxv0/wRAGB6Q8ClSS4ocGnfaur+sgE0/w+lzBG/97RdtcYrgHgEAFMdAm5PmaOCn2rq/py2q1ZOaPPf' +
|
|
40
|
+
'kWS1ET9wBwAmYVRwV2HLWjGJo4JN3f+mwObfaf4gAMDeQsDKJBsLHRV89wQ0/jNHgeWwwpa2ue2qE/yGgwAAs4WAzUlWFLi0r5Y8' +
|
|
41
|
+
'Kjga8ftagUt7R9tVG/1mQ5wBgH0IAfdn5lzAk0mWFjYqeFrbVW8orPl/N8mbC9vGX7Rd9Uq/zeAOABxIEHhNku2FLev1o0cCy0p5' +
|
|
42
|
+
'PFFg89+p+YMAAAcbAt6bZG2BS3uwqfsPL2Djf3+hBxTXjd72GRAA4KBDwI5CT5B/ZvSJeuNu/tuS3FTiJEfbVTf7jQUBAOZjVHB3' +
|
|
43
|
+
'Ycta1dT9M2Ns/r9Ksi5G/EAAgCkLAScn2VTaa3l0LuCseWz8bxvd8j+8sJ99qxE/EABgXCHg2iSnF7i0nU3db5mH5r8xydcL/HnP' +
|
|
44
|
+
'Gr2dMxBjgDCuEHBfZkYFf5nkZQUt7ZKm7s+Yq7+Km7r/TpITCyv/s21XHeK3ENwBgIUMAi9PeaOC9Vyc0B99jRMLHPHT/EEAgGJG' +
|
|
45
|
+
'Bd+XMt9CeP0B/HfrCh3xa4z4gQAApYWALxR6En3r/owKjkb8thU64rfFbxoIAFDyqOD3Ut6o4M/3ofk/mfJG/L5vxA8EAJiUEPCW' +
|
|
46
|
+
'JNcVtqxXjB4JvHMPjX/56Jb/0sLWvL3tqspvFAgAMEkh4IokZxa4tHuauv/Y85r/lUl2FbjOd43OVgAxBgiTFgLuzcyo4P8mKenk' +
|
|
47
|
+
'+hVN3f/F6H+fVFjZnmm7yrUJ3AGAQQSBQ5PcVtiyTiqw+e/S/EEAgKGFgDVJPqgSe3VZ21UrlQEEABhiCLjJifY9OqXtqhuVAQQA' +
|
|
48
|
+
'mIZRwYdVIo+O5vsfVAoQAGBaQsCbUt6oYMY84ne83wQQAGBaRwXPncIf/VwjfhBjgDDlIeCuzIwKPp1k8ZQ8/gDcAQBGjfGwJDsH' +
|
|
49
|
+
'/CPu1vxBAAD2HALOTrJ+gD/a5W1XnWyHQQAA9h4CPpdkSM3y1LarbrCzEGcAgBcNAbszcy7gsSTHTuiP8eO2q460m+AOALD/QeC1' +
|
|
50
|
+
'mcxRwe2aPwgAwMGPCp4/QUu+2IgfxCMAYE5CwB2ZeSTwnBE/wB0AmM63EP5GgUv7huYPAgAwvyHgtCQbClrS1aM1AQIAMM8h4BNJ' +
|
|
51
|
+
'lhWwlOVtV11lRyDOAABjCwEPZeZcwBNJjhrzt3+q7aoldgHcAQAWLggcnWTrGL/lbZo/CABAGSHg0iRrxvCt3tN21RoVh3gEABQT' +
|
|
52
|
+
'Am7LPI4KOuUP7gAA5Y8K7prDL9lp/iAAAJMRAlYm2TgHX2pz21UnqCgIAMDkhIDNSU4/iC9xettVG1US4gwAMHEh4L7MnAv45+z7' +
|
|
53
|
+
'IcEdbVetVT0QAIDJDwJrk6xt6n5Dkr9M8icv+L88nOSWtquuVy0QAIDhBYHrk2jygDMAACAAAAACAAAgAAAAAgAAIAAAAAIAACAA' +
|
|
54
|
+
'AAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAAAAA' +
|
|
55
|
+
'CAAAgAAAAAgAAIAAAAAIAACAAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAA' +
|
|
56
|
+
'kCRZpASAawQCQJl+PY9f+yXKC8zipRN6bUMAGISfzePXfqPyArN40zx+7Z8qrwDAwgWAo5QXmMVSAUAAYOE8Pp9fvKn7S5QY2MO1' +
|
|
57
|
+
'4W/n+Vv8UJUFAGb30Dx//S1KDOzBp+fzi7dd9XklFgCY/UWyewxJ/79UGnjeNeERVRAAmA5v9IIHRs3/f5LUKiEAUIZ/G8P3qJu6' +
|
|
58
|
+
'f66p+43KDVPZ+Dc1df9ckuPH8O3uVfHyHKoERdqR5PQxfa9rmrq/Jslvkzwy+jcwTIuT/HGSQ8b8fb+k9PGuT+xzOn9OFYAM42yT' +
|
|
59
|
+
'XhOPANh3P1ACYAD+WwkEAPbPtUoADMBmJYhHAMRjACBu/+MOAC/mBiUAJth1SuAOAO4CAP76xx0A9sM/KAEwgbzHiDsAzMFdgGeE' +
|
|
60
|
+
'NcBf/7gDMH1WKAEwQf5cCQQA5iZJfyPJjSoBTIBPtl31H8oQjwDIXD4KeDzJMSoBFOoHbVcdpwwCAKYCgHjuTzwCwAsMcG1CAMAL' +
|
|
61
|
+
'DXBNQgDACw5wLUIAYF9feD5pC1gIneYvALCwIeANSTapBDBGW9uuOkEZYgqAlDAdcFKSbyY5TDWAefLrtqteogwCAGUGgb9L8o8q' +
|
|
62
|
+
'Acyxj7Rd9WllEAAoPwhcmeRalQAO0t+3XfVxZRAAmLwg8PbMfCqXzxMA9lWX5Jq2q76oFAIAwwgD5ya5KMka1QCe59kkX05ya9tV' +
|
|
63
|
+
'dyqHAMB0hIJzkhyV5FWjf5aoCgzWT5M8Nfrnh21X/YuSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
|
|
64
|
+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +
|
|
65
|
+
'AAAAAAAAAAAAAAAAAAAAAAAA0NS9IgAAAAAAAAAAAAAAAAAAAAAAAABAvAMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAZBreL3iRKgCA' +
|
|
66
|
+
'9/4HAAAAAAAAAAAAAAAAAAAAAAAAAAAA4n2FAUBvBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIrS1L0iAIBeCQAA' +
|
|
67
|
+
'AAAAAAAAAAAAxOgDAAAAAAAQjwQAAAAAAAAAgDgbAAB6EwAAAAAAAAAAAECczARg4XrHIlUAAAAAAACIMwMAuLYDACBNAuBaXIT/' +
|
|
68
|
+
'A7IzV23ru0wqAAAAAElFTkSuQmCC',
|
|
69
|
+
'base64',
|
|
70
|
+
)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { extname, join } from 'node:path'
|
|
3
3
|
import { NextResponse } from 'next/server'
|
|
4
|
+
import { PLACEHOLDER_MARK } from './placeholder'
|
|
4
5
|
|
|
5
6
|
export const runtime = 'nodejs'
|
|
6
7
|
|
|
7
8
|
const DIR = process.env.BRAND_ASSET_DIR ?? '/data/brand'
|
|
8
9
|
|
|
10
|
+
const PLACEHOLDER_FILES = new Set(['mark.png', 'mark-email.png', 'logo.png'])
|
|
11
|
+
|
|
9
12
|
const TYPES: Record<string, string> = {
|
|
10
13
|
'.png': 'image/png',
|
|
11
14
|
'.jpg': 'image/jpeg',
|
|
@@ -32,6 +35,9 @@ export async function GET(_request: Request, { params }: { params: Promise<{ fil
|
|
|
32
35
|
headers: { 'content-type': type, 'cache-control': 'public, max-age=86400' },
|
|
33
36
|
})
|
|
34
37
|
} catch {
|
|
35
|
-
return new NextResponse('Not found', { status: 404 })
|
|
38
|
+
if (!PLACEHOLDER_FILES.has(file)) return new NextResponse('Not found', { status: 404 })
|
|
39
|
+
return new NextResponse(new Uint8Array(PLACEHOLDER_MARK), {
|
|
40
|
+
headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=3600' },
|
|
41
|
+
})
|
|
36
42
|
}
|
|
37
43
|
}
|
package/app/layout.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
2
|
import type { Metadata, Viewport } from 'next'
|
|
3
|
-
import type { ReactNode } from 'react'
|
|
3
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
4
4
|
import './globals.css'
|
|
5
5
|
|
|
6
6
|
export const metadata: Metadata = {
|
|
@@ -19,12 +19,20 @@ export const metadata: Metadata = {
|
|
|
19
19
|
export const viewport: Viewport = {
|
|
20
20
|
themeColor: BRAND.colors.accent,
|
|
21
21
|
viewportFit: 'cover',
|
|
22
|
+
width: 'device-width',
|
|
23
|
+
initialScale: 1,
|
|
24
|
+
maximumScale: 1,
|
|
25
|
+
userScalable: false,
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
25
29
|
return (
|
|
26
30
|
<html lang="en">
|
|
27
|
-
<body
|
|
31
|
+
<body
|
|
32
|
+
style={{ ['--brand-mark' as string]: `url('${BRAND.chromeMarkUrl}')` } as CSSProperties}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</body>
|
|
28
36
|
</html>
|
|
29
37
|
)
|
|
30
38
|
}
|
package/app/mail/MailSelect.tsx
CHANGED
|
@@ -12,6 +12,16 @@ export function anchoredMenuStyle(anchor: HTMLElement): React.CSSProperties {
|
|
|
12
12
|
: { ...base, top: rect.bottom + 6 }
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export const GLYPH = {
|
|
16
|
+
font: <span className={styles.selectGlyphSerif}>A</span>,
|
|
17
|
+
size: 'tT',
|
|
18
|
+
spacing: (
|
|
19
|
+
<svg viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
20
|
+
<path d="M3 2v8M1.6 3.6L3 2l1.4 1.6M1.6 8.4L3 10l1.4-1.6M6.5 4.2h4M6.5 7.8h4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
|
21
|
+
</svg>
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export const CHEVRON = (
|
|
16
26
|
<svg className={styles.selectChevron} viewBox="0 0 12 8" fill="none" aria-hidden>
|
|
17
27
|
<path d="M1 1l5 5 5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
@@ -27,9 +37,11 @@ export default function MailSelect({
|
|
|
27
37
|
placeholder = 'Select',
|
|
28
38
|
editable = false,
|
|
29
39
|
optionStyle,
|
|
40
|
+
prefix,
|
|
41
|
+
label,
|
|
30
42
|
}: {
|
|
31
43
|
value: string
|
|
32
|
-
options: Array<{ value: string; label: string }>
|
|
44
|
+
options: Array<{ value: string; label: string; closed?: string }>
|
|
33
45
|
onChange: (value: string) => void
|
|
34
46
|
ariaLabel: string
|
|
35
47
|
buttonClassName?: string
|
|
@@ -37,6 +49,8 @@ export default function MailSelect({
|
|
|
37
49
|
/** Typed values are accepted too: committed on Enter or when the field loses focus. */
|
|
38
50
|
editable?: boolean
|
|
39
51
|
optionStyle?: (value: string) => React.CSSProperties
|
|
52
|
+
prefix?: React.ReactNode
|
|
53
|
+
label?: string
|
|
40
54
|
}) {
|
|
41
55
|
const [open, setOpen] = useState(false)
|
|
42
56
|
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
|
|
@@ -64,12 +78,13 @@ export default function MailSelect({
|
|
|
64
78
|
}, [open])
|
|
65
79
|
|
|
66
80
|
const current = options.find(option => option.value === value)
|
|
81
|
+
const shown = current?.closed ?? current?.label
|
|
67
82
|
const toggle = () => {
|
|
68
83
|
if (!open && anchorRef.current) setMenuStyle(anchoredMenuStyle(anchorRef.current))
|
|
69
84
|
setOpen(prev => !prev)
|
|
70
85
|
}
|
|
71
86
|
const commitTyped = () => {
|
|
72
|
-
if (typed !== null && typed !== (
|
|
87
|
+
if (typed !== null && typed !== (shown ?? value)) onChange(typed.trim())
|
|
73
88
|
setTyped(null)
|
|
74
89
|
}
|
|
75
90
|
|
|
@@ -77,10 +92,12 @@ export default function MailSelect({
|
|
|
77
92
|
<span className={styles.select} ref={wrapRef}>
|
|
78
93
|
{editable ? (
|
|
79
94
|
<span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
|
|
95
|
+
{prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
|
|
96
|
+
{label && <span className={styles.selectLabel}>{label}</span>}
|
|
80
97
|
<input
|
|
81
98
|
className={styles.selectInput}
|
|
82
99
|
aria-label={ariaLabel}
|
|
83
|
-
value={typed ??
|
|
100
|
+
value={typed ?? shown ?? value}
|
|
84
101
|
placeholder={placeholder}
|
|
85
102
|
onFocus={() => { if (!open) toggle() }}
|
|
86
103
|
onChange={event => setTyped(event.target.value)}
|
|
@@ -107,7 +124,9 @@ export default function MailSelect({
|
|
|
107
124
|
aria-label={ariaLabel}
|
|
108
125
|
onClick={toggle}
|
|
109
126
|
>
|
|
110
|
-
<span
|
|
127
|
+
{prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
|
|
128
|
+
{label && <span className={styles.selectLabel}>{label}</span>}
|
|
129
|
+
<span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{shown ?? placeholder}</span>
|
|
111
130
|
{CHEVRON}
|
|
112
131
|
</button>
|
|
113
132
|
)}
|