@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
package/lib/receive.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { sendPush } from '@/lib/push'
3
+ import { stripOwnPixel } from '@/lib/email-html'
4
+ import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
5
+ import { ADDRESS_ALIASES, appendInbound, getAccountByAddress, inboundExists, recordContact, recordSentMeta } from '@/lib/mailbox'
6
+ import { sendMail } from '@/lib/mail-provider'
7
+
8
+ // The address we send from, and the inbox that owns mail addressed to nobody specific.
9
+ const MAIL_FROM = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
10
+ const SHARED_ADDRESS = MAIL_FROM.toLowerCase()
11
+
12
+ /** Attribute an inbound email to the accessor it was delivered to, else the shared inbox. */
13
+ export async function attributeOwner(recipients: string[]): Promise<string> {
14
+ for (const raw of recipients) {
15
+ const match = raw.match(/<([^>]+)>/)
16
+ const addr = (match ? match[1] : raw).trim().toLowerCase()
17
+ if (!addr.includes('@')) continue
18
+ const account = await getAccountByAddress(addr)
19
+ if (account?.address) {
20
+ const owner = account.address.toLowerCase()
21
+ return ADDRESS_ALIASES[owner] ?? owner
22
+ }
23
+ }
24
+ return SHARED_ADDRESS
25
+ }
26
+
27
+ export function parseSender(raw: string): { email: string; name: string | null } {
28
+ const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
29
+ if (match) return { name: match[1].replace(/^"|"$/g, '') || null, email: match[2].trim() }
30
+ return { name: null, email: raw.trim() }
31
+ }
32
+
33
+ function snippet(html: string | null, text: string | null): string {
34
+ const source = text?.trim() || (html ?? '').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ')
35
+ return source.replace(/\s+/g, ' ').trim().slice(0, 240)
36
+ }
37
+
38
+ function escapeHtml(src: string): string {
39
+ return src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
40
+ }
41
+
42
+ export type ReceivedEmail = {
43
+ html: string | null
44
+ text: string | null
45
+ from: string
46
+ to: string[]
47
+ cc: string[]
48
+ bcc: string[]
49
+ replyTo: string[]
50
+ subject: string
51
+ headers: Record<string, unknown>
52
+ createdAt: string
53
+ attachments: Array<{ filename: string; contentType?: string }>
54
+ }
55
+
56
+ // The email.received webhook payload carries only metadata (from/to/subject) — the body
57
+ // lives on Resend and must be pulled from the receiving endpoint, or the inbox stores blanks.
58
+ export async function fetchReceivedEmail(emailId: string, apiKey: string): Promise<ReceivedEmail | null> {
59
+ try {
60
+ const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(emailId)}`, {
61
+ headers: { Authorization: `Bearer ${apiKey}` },
62
+ })
63
+ if (!response.ok) return null
64
+ const data = (await response.json()) as Record<string, unknown>
65
+ const rawAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
66
+ return {
67
+ html: data.html ? String(data.html) : null,
68
+ text: data.text ? String(data.text) : null,
69
+ from: String(data.from ?? ''),
70
+ to: Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')],
71
+ cc: Array.isArray(data.cc) ? (data.cc as string[]) : [],
72
+ bcc: Array.isArray(data.bcc) ? (data.bcc as string[]) : [],
73
+ replyTo: Array.isArray(data.reply_to) ? (data.reply_to as string[]) : [],
74
+ subject: String(data.subject ?? '(no subject)'),
75
+ headers: data.headers && typeof data.headers === 'object' ? (data.headers as Record<string, unknown>) : {},
76
+ createdAt: String(data.created_at ?? new Date().toISOString()),
77
+ attachments: rawAttachments
78
+ .map(entry => ({
79
+ filename: String(entry.filename ?? entry.name ?? 'attachment'),
80
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
81
+ }))
82
+ .filter(entry => entry.filename),
83
+ }
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ export type SendAttachment = { filename: string; content: string; contentType?: string; contentId?: string; size?: number }
90
+
91
+ /**
92
+ * Copy attachment bytes onto our own storage as the message arrives. Provider download URLs
93
+ * expire and disappear with the account, so a stored filename alone would outlive the file.
94
+ */
95
+ async function rehostAttachments(
96
+ emailId: string,
97
+ files: SendAttachment[],
98
+ ): Promise<Array<{ filename: string; contentType?: string; key?: string; size?: number; contentId?: string }>> {
99
+ if (!files.length) return []
100
+ const { putObject } = await import('@/lib/r2')
101
+ return Promise.all(
102
+ files.map(async (file, index) => {
103
+ if (!file.content) {
104
+ return { filename: file.filename, contentType: file.contentType, size: file.size, contentId: file.contentId }
105
+ }
106
+ const bytes = Buffer.from(file.content, 'base64')
107
+ // contentId is what separates an embedded signature image from a real attachment.
108
+ const meta = { filename: file.filename, contentType: file.contentType, size: bytes.length, contentId: file.contentId }
109
+ const safeName = (file.filename || 'attachment').replace(/[^\w.\- ]+/g, '_').slice(-120)
110
+ const key = `attachments/${emailId}/${index}-${safeName}`
111
+ // Written to our own bucket and served back through this domain. The provider's
112
+ // own link expires, dies with the account, and points at a host outside our
113
+ // control; the bucket URL would be a third host again, so neither is handed out.
114
+ return (await putObject(key, bytes, file.contentType)) ? { ...meta, key } : meta
115
+ }),
116
+ )
117
+ }
118
+
119
+ async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<SendAttachment[]> {
120
+ try {
121
+ const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(emailId)}/attachments`, {
122
+ headers: { Authorization: `Bearer ${apiKey}` },
123
+ })
124
+ if (!response.ok) return []
125
+ const data = (await response.json()) as { data?: Array<Record<string, unknown>> }
126
+ const list = Array.isArray(data.data) ? data.data : []
127
+ const out: SendAttachment[] = []
128
+ for (const entry of list) {
129
+ const url = entry.download_url ? String(entry.download_url) : ''
130
+ const file: SendAttachment = {
131
+ filename: String(entry.filename ?? 'attachment'),
132
+ content: '',
133
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
134
+ contentId: entry.content_id ? String(entry.content_id).replace(/^<|>$/g, '') : undefined,
135
+ size: Number(entry.size ?? 0) || undefined,
136
+ }
137
+ try {
138
+ const binary = url ? await fetch(url) : null
139
+ if (binary?.ok) file.content = Buffer.from(await binary.arrayBuffer()).toString('base64')
140
+ } catch (err) {
141
+ console.warn(`[mail] attachment pull failed for ${emailId}/${file.filename}:`, err)
142
+ }
143
+ out.push(file)
144
+ }
145
+ return out
146
+ } catch {
147
+ return []
148
+ }
149
+ }
150
+
151
+ // Forward the whole received email — full body + attachments — to the mail accounts.
152
+ // Forward the inbound copy ONLY to the account that owns the mail (its login email),
153
+ // never to every accessor — a member must not receive mail that isn't theirs.
154
+ export async function forwardToAccounts(
155
+ emailId: string,
156
+ inbound: ReceivedEmail,
157
+ ownerAddress: string | null,
158
+ files?: SendAttachment[],
159
+ ): Promise<void> {
160
+ const owningAccount = ownerAddress ? await getAccountByAddress(ownerAddress) : null
161
+ const recipient = owningAccount?.email ?? FORWARD_RECIPIENTS[0] // fallback: the admin
162
+ if (!recipient) return
163
+ // Forwarding exists to put a copy somewhere the recipient already reads. Once this
164
+ // domain's MX points here, an address on it is somewhere we read — so forwarding to
165
+ // one would deliver back into this handler and forward again, without end.
166
+ if (recipient.trim().toLowerCase().endsWith(`@${MAIL_DOMAIN}`)) return
167
+ const from = MAIL_FROM
168
+ // Attachment bytes only come from Resend's receiving API; under another provider the
169
+ // forward still goes out, just without re-attaching the files.
170
+ const resendKey = process.env.RESEND_API_KEY
171
+ const attachments = (files ?? (resendKey ? await fetchAttachmentBytes(emailId, resendKey) : [])).filter(file => file.content)
172
+ const header = `<div style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#5A5170;border-left:3px solid ${BRAND.colors.accent};padding:4px 0 4px 12px;margin:0 0 16px;">---------- Forwarded message ----------<br><strong>From:</strong> ${escapeHtml(inbound.from)}<br><strong>Subject:</strong> ${escapeHtml(inbound.subject)}</div>`
173
+ try {
174
+ const data = await sendMail({
175
+ from: from.replace(/^.*<|>$/g, ''),
176
+ fromName: `${BRAND.name} Mail`,
177
+ to: [recipient],
178
+ replyTo: inbound.from || undefined,
179
+ subject: inbound.subject.startsWith('Fwd:') ? inbound.subject : `Fwd: ${inbound.subject}`,
180
+ text: inbound.text?.trim() || snippet(inbound.html, inbound.text) || ' ',
181
+ ...(inbound.html ? { html: `${header}${stripOwnPixel(inbound.html)}` } : {}),
182
+ ...(attachments.length ? { attachments } : {}),
183
+ })
184
+ // Tag as automated so this "Fwd:" copy never shows up in the Sent folder.
185
+ if (data?.id) await recordSentMeta(data.id, null, true).catch(() => {})
186
+ } catch (err) {
187
+ console.warn('[mail] inbound forward failed:', err)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Everything that has to happen when the provider hands us a received message, from the
193
+ * webhook or from a later reconciliation by id. The claim around it belongs to the caller:
194
+ * the webhook keys it on the delivery id, a re-run on the message id.
195
+ */
196
+ export async function ingestReceived(
197
+ emailId: string,
198
+ data: Record<string, unknown> = {},
199
+ ): Promise<{ owner: string; subject: string; from: string }> {
200
+ const apiKey = process.env.RESEND_API_KEY
201
+ // The webhook payload has no body — pull the full email (html/text/attachments) from Resend.
202
+ const full = apiKey ? await fetchReceivedEmail(emailId, apiKey) : null
203
+
204
+ const payloadAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
205
+ const inbound = {
206
+ id: emailId,
207
+ from: full?.from || String(data.from ?? ''),
208
+ to: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
209
+ cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
210
+ bcc: full?.bcc ?? (Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
211
+ replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
212
+ subject: full?.subject || String(data.subject ?? '(no subject)'),
213
+ html: full?.html ?? (data.html ? String(data.html) : null),
214
+ text: full?.text ?? (data.text ? String(data.text) : null),
215
+ headers: full?.headers ?? {},
216
+ receivedAt: full?.createdAt || String(data.created_at ?? new Date().toISOString()),
217
+ read: false,
218
+ attachments:
219
+ full?.attachments ??
220
+ payloadAttachments
221
+ .map(entry => ({
222
+ filename: String(entry.filename ?? entry.name ?? 'attachment'),
223
+ contentType: entry.content_type ? String(entry.content_type) : undefined,
224
+ }))
225
+ .filter(entry => entry.filename),
226
+ // Attribute (and therefore forward) to the accessor actually addressed — to, cc, OR bcc.
227
+ // Anything not matching a member's personal address stays in the shared (admin) inbox,
228
+ // so a member never receives mail they aren't a party to.
229
+ owner: await attributeOwner([
230
+ ...(Array.isArray(full?.to) ? full!.to : []),
231
+ ...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
232
+ ...(full?.cc ?? []),
233
+ ...(full?.bcc ?? []),
234
+ ]),
235
+ }
236
+ // Pull the bytes once: they become our stored copy and the forward's attachments.
237
+ const files = apiKey ? await fetchAttachmentBytes(emailId, apiKey) : []
238
+ if (files.length) inbound.attachments = await rehostAttachments(emailId, files)
239
+ const forwardable = files.filter(file => file.content)
240
+
241
+ await appendInbound(inbound)
242
+ const sender = parseSender(inbound.from)
243
+ await recordContact(sender.email, sender.name)
244
+ await sendPush(inbound.owner, {
245
+ title: sender.name || sender.email || 'New mail',
246
+ body: inbound.subject,
247
+ tag: emailId,
248
+ }).catch(() => {})
249
+ await forwardToAccounts(
250
+ emailId,
251
+ full ?? {
252
+ html: inbound.html,
253
+ text: inbound.text,
254
+ from: inbound.from,
255
+ to: inbound.to,
256
+ cc: inbound.cc,
257
+ bcc: inbound.bcc,
258
+ replyTo: inbound.replyTo,
259
+ subject: inbound.subject,
260
+ headers: inbound.headers,
261
+ createdAt: inbound.receivedAt,
262
+ attachments: inbound.attachments,
263
+ },
264
+ inbound.owner,
265
+ forwardable,
266
+ )
267
+ return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
268
+ }
269
+
270
+ export type ReceivedSummary = { id: string; from: string; to: string[]; subject: string; createdAt: string }
271
+
272
+ /**
273
+ * Everything the provider has received for this account since `since`, newest first. The
274
+ * provider keeps this list, which is what makes a missed delivery recoverable at all: the
275
+ * webhook is a nudge, the list is the record.
276
+ */
277
+ export async function listReceived(apiKey: string, since: Date): Promise<ReceivedSummary[]> {
278
+ const out: ReceivedSummary[] = []
279
+ let after: string | null = null
280
+ for (let page = 0; page < 200; page += 1) {
281
+ const params = new URLSearchParams({ limit: '50' })
282
+ if (after) params.set('after', after)
283
+ const response = await fetch(`https://api.resend.com/emails/receiving?${params}`, {
284
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
285
+ })
286
+ if (!response.ok) throw new Error(`provider list failed (${response.status})`)
287
+ const body = (await response.json()) as { data?: Array<Record<string, unknown>>; has_more?: boolean }
288
+ const items = body.data ?? []
289
+ if (!items.length) break
290
+ let reachedSince = false
291
+ for (const item of items) {
292
+ const createdAt = String(item.created_at ?? '')
293
+ if (createdAt && new Date(createdAt) < since) { reachedSince = true; break }
294
+ out.push({
295
+ id: String(item.id),
296
+ from: String(item.from ?? ''),
297
+ to: Array.isArray(item.to) ? (item.to as string[]) : [],
298
+ subject: String(item.subject ?? ''),
299
+ createdAt,
300
+ })
301
+ }
302
+ if (reachedSince || !body.has_more) break
303
+ const last = String(items[items.length - 1].id)
304
+ if (last === after) break
305
+ after = last
306
+ }
307
+ return out
308
+ }
309
+
310
+ /** Received by the provider for an address on this tenant's domain, but absent from the mailbox. */
311
+ export async function findMissingReceived(apiKey: string, since: Date): Promise<ReceivedSummary[]> {
312
+ const received = await listReceived(apiKey, since)
313
+ const ours = received.filter(item =>
314
+ item.to.some(address => address.trim().toLowerCase().endsWith(`@${MAIL_DOMAIN}`)),
315
+ )
316
+ const present = await inboundExists(ours.map(item => item.id))
317
+ return ours.filter(item => !present.has(item.id))
318
+ }
@@ -0,0 +1,22 @@
1
+ import { BRAND } from '@/lib/brand'
2
+ import { listAccounts, readSentMessage, readSentMeta } from '@/lib/mailbox'
3
+
4
+ const SHARED_ADDRESS = (process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim().toLowerCase()
5
+
6
+ /**
7
+ * Whether this mailbox may read a sent message: the one it is assigned to, else the one
8
+ * whose address sent it, with unattributed sends belonging to the shared address. Mirrors
9
+ * the scoping of the Sent list, so nothing is readable by id that is not listable.
10
+ */
11
+ export async function mayReadSent(account: { address: string | null }, id: string): Promise<boolean> {
12
+ const scope = account.address?.trim().toLowerCase()
13
+ if (!scope) return false
14
+ const metas: Record<string, { owner: string | null }> = await readSentMeta([id]).catch(() => ({}))
15
+ const meta = metas[id]
16
+ if (meta?.owner) return meta.owner.toLowerCase() === scope
17
+ const stored = await readSentMessage(id).catch(() => null)
18
+ if (!stored) return scope === SHARED_ADDRESS
19
+ const from = (stored.from.match(/<([^>]+)>/)?.[1] ?? stored.from).trim().toLowerCase()
20
+ const known = new Set((await listAccounts()).filter(entry => entry.address).map(entry => entry.address!.toLowerCase()))
21
+ return (known.has(from) ? from : SHARED_ADDRESS) === scope
22
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash, createHmac } from 'node:crypto'
2
+
3
+ const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
4
+ const hmac = (key: Buffer | string, value: string) => createHmac('sha256', key).update(value, 'utf8').digest()
5
+
6
+ export function sesConfigured(): boolean {
7
+ return Boolean(process.env.SES_ACCESS_KEY_ID && process.env.SES_SECRET_ACCESS_KEY)
8
+ }
9
+
10
+ export async function sesSendRaw(raw: string, region = process.env.AWS_SES_REGION ?? 'eu-north-1'): Promise<string | null> {
11
+ const id = process.env.SES_ACCESS_KEY_ID
12
+ const secret = process.env.SES_SECRET_ACCESS_KEY
13
+ if (!id || !secret) throw new Error('SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY must be configured')
14
+
15
+ const host = `email.${region}.amazonaws.com`
16
+ const path = '/v2/email/outbound-emails'
17
+ const body = JSON.stringify({ Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } } })
18
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
19
+ const stamp = amzDate.slice(0, 8)
20
+ const payloadHash = sha256(body)
21
+
22
+ const canonicalHeaders = `content-type:application/json\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
23
+ const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
24
+ const canonicalRequest = ['POST', path, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
25
+ const scope = `${stamp}/${region}/ses/aws4_request`
26
+ const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
27
+
28
+ let key = hmac(`AWS4${secret}`, stamp)
29
+ for (const part of [region, 'ses', 'aws4_request']) key = hmac(key, part)
30
+ const signature = createHmac('sha256', key).update(toSign, 'utf8').digest('hex')
31
+
32
+ const response = await fetch(`https://${host}${path}`, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'content-type': 'application/json',
36
+ 'x-amz-content-sha256': payloadHash,
37
+ 'x-amz-date': amzDate,
38
+ authorization: `AWS4-HMAC-SHA256 Credential=${id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
39
+ },
40
+ body,
41
+ })
42
+ const text = await response.text()
43
+ if (!response.ok) {
44
+ let detail = text.slice(0, 200)
45
+ try { detail = (JSON.parse(text) as { message?: string }).message ?? detail } catch {}
46
+ throw new Error(`SES send failed (HTTP ${response.status}): ${detail}`)
47
+ }
48
+ return (JSON.parse(text) as { MessageId?: string }).MessageId ?? null
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",
@@ -51,7 +51,7 @@
51
51
  "@tiptap/react": "^3.31.3",
52
52
  "@tiptap/starter-kit": "^3.31.3",
53
53
  "@vercel/blob": "^2.6.1",
54
- "next": "16.2.6",
54
+ "next": "^16.3.5",
55
55
  "react": "19.2.4",
56
56
  "react-dom": "19.2.4",
57
57
  "resend": "^6.12.3",
@@ -1,7 +1,7 @@
1
1
  # Brand assets
2
2
 
3
3
  > **Never deploy or `git push` unless asked for it in that request.** Pushing
4
- > `master` auto-deploys every live mailbox. See the deploying section in the
4
+ > `master` auto-deploys the Novacraft mailbox. See the deploying section in the
5
5
  > root [README](../README.md).
6
6
 
7
7
  **No tenant's artwork belongs in this repository.** Each mailbox keeps its own
@@ -26,6 +26,10 @@ NEXT_PUBLIC_BRAND_CHROME_MARK_URL=/brand/mark.png
26
26
  Any of these may instead be a URL on a host the tenant already owns; nothing in
27
27
  the app requires the files to be served from here.
28
28
 
29
+ Until a tenant supplies them, `/brand/mark.png`, `/brand/mark-email.png` and
30
+ `/brand/logo.png` answer with the same neutral envelope as the app icons, so a
31
+ fresh install carries no other tenant's mark.
32
+
29
33
  ## App icons
30
34
 
31
35
  `public/icon-192.png`, `public/icon-512.png`, `public/icon-maskable-512.png`,