@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
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|
package/lib/ses-send.ts
ADDED
|
@@ -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.
|
|
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.
|
|
54
|
+
"next": "^16.3.5",
|
|
55
55
|
"react": "19.2.4",
|
|
56
56
|
"react-dom": "19.2.4",
|
|
57
57
|
"resend": "^6.12.3",
|
package/public/brand/README.md
CHANGED
|
@@ -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
|
|
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`,
|