@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.
Files changed (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +23 -4
  18. package/app/mail/RichEditor.tsx +167 -53
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +643 -76
  21. package/app/mail/page.tsx +1062 -247
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. 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
- export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
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 apiKey = process.env.RESEND_API_KEY
39
- if (apiKey) {
40
- const from = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail)
41
- .replace(/^.*<|>$/g, '')
42
- .trim()
43
- const resetUrl = `${publicOrigin(req)}/mail/reset?token=${token}`
44
- const resend = new Resend(apiKey)
45
- try {
46
- const { data } = await resend.emails.send({
47
- from: `${BRAND.name} Mail <${from}>`,
48
- to: [email],
49
- subject: `Reset your ${BRAND.name} Mail password`,
50
- text: `Someone requested a password reset for ${BRAND.name} Mail.\n\nSet a new password: ${resetUrl}\n\nThis link expires in 30 minutes. If you didn't request it, ignore this email — your password won't change.`,
51
- html: `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;line-height:1.6;color:#1A1030;">
52
- <p style="margin:0 0 12px;color:${BRAND.colors.accent};font-weight:600;">Reset your ${BRAND.name} Mail password</p>
53
- <p style="margin:0 0 16px;">Click below to set a new password. The link expires in 30 minutes.</p>
54
- <a href="${resetUrl}" style="display:inline-block;background:${BRAND.colors.accent};color:#fff;text-decoration:none;font-weight:600;padding:11px 20px;border-radius:8px;">Set a new password</a>
55
- <p style="margin:16px 0 0;color:#8E84A8;font-size:13px;">Didn't request this? Ignore this email — your password won't change.</p>
56
- </div>`,
57
- })
58
- if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
59
- } catch (err) {
60
- console.warn('[mail] reset email failed:', err)
61
- }
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 = 2 * 1024 * 1024
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 logo under 2MB.' }, { status: 413 })
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>{children}</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
  }
@@ -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 !== (current?.label ?? value)) onChange(typed.trim())
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 ?? current?.label ?? value}
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 style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</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
  )}