@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.
- 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 +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- 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
|
@@ -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
|
|
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
|
)}
|