@novacraft-engineering/mailbox 0.1.0 → 0.2.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 (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 +14 -3
  18. package/app/mail/RichEditor.tsx +168 -52
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +624 -70
  21. package/app/mail/page.tsx +1059 -244
  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
  }
@@ -27,6 +27,9 @@ export default function MailSelect({
27
27
  placeholder = 'Select',
28
28
  editable = false,
29
29
  optionStyle,
30
+ prefix,
31
+ compact = false,
32
+ title,
30
33
  }: {
31
34
  value: string
32
35
  options: Array<{ value: string; label: string }>
@@ -37,6 +40,11 @@ export default function MailSelect({
37
40
  /** Typed values are accepted too: committed on Enter or when the field loses focus. */
38
41
  editable?: boolean
39
42
  optionStyle?: (value: string) => React.CSSProperties
43
+ /** A quiet glyph before the value, so three selects in a row can be told apart. */
44
+ prefix?: React.ReactNode
45
+ /** Closed, show only the glyph; the value stays in the title and the menu. */
46
+ compact?: boolean
47
+ title?: string
40
48
  }) {
41
49
  const [open, setOpen] = useState(false)
42
50
  const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
@@ -76,7 +84,8 @@ export default function MailSelect({
76
84
  return (
77
85
  <span className={styles.select} ref={wrapRef}>
78
86
  {editable ? (
79
- <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
87
+ <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`} title={title}>
88
+ {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
80
89
  <input
81
90
  className={styles.selectInput}
82
91
  aria-label={ariaLabel}
@@ -104,10 +113,12 @@ export default function MailSelect({
104
113
  className={`${styles.selectBtn} ${buttonClassName ?? ''}`}
105
114
  aria-haspopup="listbox"
106
115
  aria-expanded={open}
107
- aria-label={ariaLabel}
116
+ aria-label={compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : ariaLabel}
117
+ title={title ?? (compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : undefined)}
108
118
  onClick={toggle}
109
119
  >
110
- <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>
120
+ {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
121
+ {!compact && <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>}
111
122
  {CHEVRON}
112
123
  </button>
113
124
  )}