@novacraft-engineering/mailbox 0.4.4 → 0.4.17

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 (41) hide show
  1. package/README.md +17 -0
  2. package/app/api/mail/accessors/reset-link/route.ts +46 -0
  3. package/app/api/mail/accessors/route.ts +16 -0
  4. package/app/api/mail/emails/[id]/route.ts +11 -3
  5. package/app/api/mail/emails/route.ts +14 -4
  6. package/app/api/mail/inbox/counts/route.ts +7 -7
  7. package/app/api/mail/inbox/route.ts +23 -4
  8. package/app/api/mail/maintenance/received/route.ts +11 -6
  9. package/app/api/mail/maintenance/rejudge/route.ts +32 -0
  10. package/app/api/mail/recovery/route.ts +95 -0
  11. package/app/api/mail/recovery/verify/route.ts +29 -0
  12. package/app/api/mail/request-reset/route.ts +36 -8
  13. package/app/api/mail/scheduled/route.ts +48 -0
  14. package/app/api/mail/scheduled/run/route.ts +19 -0
  15. package/app/api/mail/send/route.ts +39 -7
  16. package/app/api/mail/threads/route.ts +1 -1
  17. package/app/api/share/[id]/download/route.ts +71 -0
  18. package/app/api/share/[id]/route.ts +11 -6
  19. package/app/globals.css +7 -0
  20. package/app/mail/AttachmentLightbox.tsx +1 -1
  21. package/app/mail/ConfirmDialog.tsx +53 -8
  22. package/app/mail/InstallGuide.tsx +133 -0
  23. package/app/mail/page.module.css +254 -1
  24. package/app/mail/page.tsx +539 -87
  25. package/app/mail/pwa.test.ts +30 -0
  26. package/app/mail/pwa.ts +25 -1
  27. package/app/mail/search.ts +17 -0
  28. package/app/share/[id]/page.tsx +61 -11
  29. package/app/share/[id]/share.module.css +19 -0
  30. package/lib/dev-auth.test.ts +15 -0
  31. package/lib/dev-auth.ts +29 -9
  32. package/lib/mail-provider.test.ts +23 -0
  33. package/lib/mail-provider.ts +39 -3
  34. package/lib/mailbox.ts +578 -51
  35. package/lib/receive.test.ts +25 -0
  36. package/lib/receive.ts +117 -17
  37. package/lib/scheduled.ts +168 -0
  38. package/lib/ses-send.ts +14 -2
  39. package/lib/share-ticket.ts +42 -0
  40. package/package.json +1 -1
  41. package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ const IPHONE_SAFARI =
5
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
6
+ const IPHONE_CHROME =
7
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.54 Mobile/15E148 Safari/604.1'
8
+ const IPHONE_FIREFOX =
9
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15'
10
+ const IPAD_SAFARI =
11
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15'
12
+ const MAC_SAFARI = IPAD_SAFARI
13
+ const ANDROID_CHROME =
14
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
15
+ const DESKTOP_CHROME =
16
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
17
+
18
+ test('the install guide is offered on iOS and nowhere else', async () => {
19
+ const { classifyIos } = await import('./pwa.ts')
20
+
21
+ assert.equal(classifyIos(IPHONE_SAFARI, 5), 'safari')
22
+ assert.equal(classifyIos(IPAD_SAFARI, 5), 'safari', 'iPadOS reports a Mac agent; touch points separate it')
23
+
24
+ assert.equal(classifyIos(IPHONE_CHROME, 5), 'other')
25
+ assert.equal(classifyIos(IPHONE_FIREFOX, 5), 'other')
26
+
27
+ assert.equal(classifyIos(MAC_SAFARI, 0), null, 'a desktop Mac is not an install candidate')
28
+ assert.equal(classifyIos(ANDROID_CHROME, 5), null, 'Android has beforeinstallprompt already')
29
+ assert.equal(classifyIos(DESKTOP_CHROME, 0), null)
30
+ })
package/app/mail/pwa.ts CHANGED
@@ -30,6 +30,29 @@ const subscribePermission = (notify: () => void) => {
30
30
  return () => window.removeEventListener(PERMISSION_EVENT, notify)
31
31
  }
32
32
 
33
+ export type IosBrowser = 'safari' | 'other'
34
+
35
+ /**
36
+ * WebKit has never implemented beforeinstallprompt and exposes nothing else that answers
37
+ * "can this be installed", so on iOS the platform itself is the only signal. iPadOS
38
+ * reports a Mac user agent, which the touch-point count is what separates from a desktop.
39
+ */
40
+ export function classifyIos(agent: string, touchPoints: number): IosBrowser | null {
41
+ const ios = /iphone|ipad|ipod/i.test(agent) || (/macintosh/i.test(agent) && touchPoints > 1)
42
+ if (!ios) return null
43
+ return /crios|fxios|edgios|opios/i.test(agent) ? 'other' : 'safari'
44
+ }
45
+
46
+ let iosCache: IosBrowser | null | undefined
47
+ function detectIos(): IosBrowser | null {
48
+ if (iosCache !== undefined) return iosCache
49
+ if (typeof navigator === 'undefined') return null
50
+ iosCache = classifyIos(navigator.userAgent, navigator.maxTouchPoints)
51
+ return iosCache
52
+ }
53
+
54
+ const neverChanges = () => () => {}
55
+
33
56
  /**
34
57
  * Chrome only fires beforeinstallprompt once per page load and refuses to let it be
35
58
  * replayed later, so the event is captured and held rather than requested on click.
@@ -37,6 +60,7 @@ const subscribePermission = (notify: () => void) => {
37
60
  export function useInstall() {
38
61
  const [deferred, setDeferred] = useState<InstallPrompt | null>(null)
39
62
  const [accepted, setAccepted] = useState(false)
63
+ const ios = useSyncExternalStore(neverChanges, detectIos, () => null)
40
64
  const installed = useSyncExternalStore(subscribeStandalone, standalone, () => false) || accepted
41
65
 
42
66
  useEffect(() => {
@@ -70,7 +94,7 @@ export function useInstall() {
70
94
  return outcome
71
95
  }, [deferred])
72
96
 
73
- return { canInstall: Boolean(deferred), installed, install }
97
+ return { canInstall: Boolean(deferred), installed, install, ios }
74
98
  }
75
99
 
76
100
  const serverKey = (base64: string): ArrayBuffer => {
@@ -21,6 +21,8 @@ export type SearchDoc = {
21
21
  read: boolean
22
22
  starred: boolean
23
23
  hasAttachment: boolean
24
+ /** Whether the mailbox was written to, copied in, or reached some other way. */
25
+ addressed?: 'direct' | 'copied' | 'other'
24
26
  labels: string[]
25
27
  folder: string
26
28
  }
@@ -129,6 +131,10 @@ function matchTerm(term: Term, doc: SearchDoc): boolean {
129
131
  if (value === 'read') return doc.read
130
132
  if (value === 'starred') return doc.starred
131
133
  if (value === 'unstarred') return !doc.starred
134
+ // Copied in, written to, or arrived some other way — a blind copy, a list, an alias.
135
+ if (value === 'copied' || value === 'cc') return doc.addressed === 'copied'
136
+ if (value === 'direct') return doc.addressed === 'direct'
137
+ if (value === 'indirect' || value === 'other') return doc.addressed === 'other'
132
138
  return true
133
139
  case 'has':
134
140
  if (value === 'attachment' || value === 'attachments') return doc.hasAttachment
@@ -172,6 +178,8 @@ export type ServerSearch = {
172
178
  unread?: boolean
173
179
  starred?: boolean
174
180
  hasAttachment?: boolean
181
+ /** direct | copied | other | not-copied */
182
+ addressed?: string
175
183
  }
176
184
 
177
185
  /**
@@ -184,6 +192,10 @@ export function splitForServer(query: ParsedQuery): { server: ServerSearch; resi
184
192
  const residual: Term[][] = []
185
193
  for (const group of query.groups) {
186
194
  const term = group.length === 1 ? group[0] : null
195
+ if (term?.negated && term.field === 'is' && (term.value === 'copied' || term.value === 'cc')) {
196
+ server.addressed = 'not-copied'
197
+ continue
198
+ }
187
199
  if (!term || term.negated) {
188
200
  residual.push(group)
189
201
  continue
@@ -195,6 +207,10 @@ export function splitForServer(query: ParsedQuery): { server: ServerSearch; resi
195
207
  else if (term.field === 'is' && term.value === 'unread') server.unread = true
196
208
  else if (term.field === 'is' && term.value === 'starred') server.starred = true
197
209
  else if (term.field === 'has' && term.value === 'attachment') server.hasAttachment = true
210
+ // Pushed down so the whole mailbox is filtered, not the page the client happens to hold.
211
+ else if (term.field === 'is' && (term.value === 'copied' || term.value === 'cc')) server.addressed = 'copied'
212
+ else if (term.field === 'is' && term.value === 'direct') server.addressed = 'direct'
213
+ else if (term.field === 'is' && (term.value === 'indirect' || term.value === 'other')) server.addressed = 'other'
198
214
  else residual.push(group)
199
215
  }
200
216
  server.text = words.join(' ')
@@ -211,6 +227,7 @@ export function serverSearchParams(raw: string): Record<string, string> {
211
227
  if (server.unread) params.unread = '1'
212
228
  if (server.starred) params.starred = '1'
213
229
  if (server.hasAttachment) params.attachment = '1'
230
+ if (server.addressed) params.addressed = server.addressed
214
231
  return params
215
232
  }
216
233
 
@@ -46,9 +46,12 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
46
46
  const [meta, setMeta] = useState<Meta | null>(null)
47
47
  const [gone, setGone] = useState(false)
48
48
  const [password, setPassword] = useState('')
49
+ const [reveal, setReveal] = useState(false)
49
50
  const [busy, setBusy] = useState(false)
50
51
  const [error, setError] = useState('')
51
52
  const [done, setDone] = useState(false)
53
+ // Kept so a reader whose browser refuses the scripted download still has a plain link.
54
+ const [fallback, setFallback] = useState('')
52
55
 
53
56
  useEffect(() => {
54
57
  let cancelled = false
@@ -79,10 +82,36 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
79
82
  setError(data?.error ?? 'That did not work.')
80
83
  return
81
84
  }
85
+ // Ask for the headers first. A download that cannot start otherwise leaves this page
86
+ // sitting on "Starting download…" for ever, because navigating away and failing
87
+ // reports nothing back — which is indistinguishable from a dead button.
88
+ try {
89
+ const ready = await fetch(data.url, { method: 'HEAD' })
90
+ if (!ready.ok) {
91
+ const reason = await ready.json().catch(() => null)
92
+ setError(reason?.error ?? 'That file could not be fetched. Ask the sender for a fresh link.')
93
+ return
94
+ }
95
+ } catch {
96
+ setError('Could not reach the file. Check your connection and try again.')
97
+ return
98
+ }
82
99
  setDone(true)
83
- // Navigating rather than opening a tab: a popup blocker would eat the tab,
84
- // and the signed URL only lasts two minutes.
85
- window.location.href = data.url
100
+ // An anchor carrying `download`, not a navigation. Pointing location at a response
101
+ // marked as an attachment is supposed to save it and stay put, and often simply does
102
+ // nothing instead — no download, no error, no way to tell. The anchor is the path
103
+ // browsers actually honour for a same-origin file, and it never leaves the page.
104
+ setFallback(data.url)
105
+ const link = document.createElement('a')
106
+ link.href = data.url
107
+ link.download = data.filename ?? ''
108
+ link.rel = 'noopener'
109
+ document.body.appendChild(link)
110
+ link.click()
111
+ // Removed on a later tick, never in the same one. Chromium starts the download from
112
+ // the live element, and tearing it out synchronously cancels the fetch before it
113
+ // begins — which looks exactly like a button that does nothing.
114
+ window.setTimeout(() => link.remove(), 2000)
86
115
  } catch {
87
116
  setError('Could not reach the server. Try again.')
88
117
  } finally {
@@ -90,6 +119,17 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
90
119
  }
91
120
  }, [id, password])
92
121
 
122
+ // The browser gives no signal that a download began, so if this page is still here and
123
+ // still saying so a while later, say plainly that it may not have worked.
124
+ useEffect(() => {
125
+ if (!done) return
126
+ const timer = window.setTimeout(() => {
127
+ setError('If nothing has downloaded, your network may be blocking it. Tell the sender.')
128
+ setDone(false)
129
+ }, 20000)
130
+ return () => window.clearTimeout(timer)
131
+ }, [done])
132
+
93
133
  if (gone) {
94
134
  return (
95
135
  <main className={styles.wrap} style={brandStyle}>
@@ -131,18 +171,28 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
131
171
  {meta.needsPassword && !done && (
132
172
  <label className={styles.field}>
133
173
  <span>Password</span>
134
- <input
135
- type="password"
136
- value={password}
137
- autoFocus
138
- placeholder="The password you were given"
139
- onChange={event => setPassword(event.target.value)}
140
- onKeyDown={event => event.key === 'Enter' && password && claim()}
141
- />
174
+ <div className={styles.pwWrap}>
175
+ <input
176
+ type={reveal ? 'text' : 'password'}
177
+ value={password}
178
+ autoFocus
179
+ placeholder="The password you were given"
180
+ onChange={event => setPassword(event.target.value)}
181
+ onKeyDown={event => event.key === 'Enter' && password && claim()}
182
+ />
183
+ <button type="button" className={styles.pwToggle} onClick={() => setReveal(show => !show)}>
184
+ {reveal ? 'Hide' : 'Show'}
185
+ </button>
186
+ </div>
142
187
  </label>
143
188
  )}
144
189
 
145
190
  {error && <p className={styles.error} role="alert">{error}</p>}
191
+ {fallback && (
192
+ <p className={styles.error}>
193
+ Or <a href={fallback} download>open the file directly</a>.
194
+ </p>
195
+ )}
146
196
 
147
197
  <button
148
198
  type="button"
@@ -81,6 +81,25 @@
81
81
  text-transform: uppercase;
82
82
  color: #6e6a7c;
83
83
  }
84
+ .pwWrap { position: relative; display: flex; }
85
+ .pwWrap input { flex: 1; min-width: 0; padding-right: 62px; }
86
+ .pwToggle {
87
+ position: absolute;
88
+ right: 6px;
89
+ top: 50%;
90
+ transform: translateY(-50%);
91
+ padding: 6px 10px;
92
+ border: 0;
93
+ border-radius: 8px;
94
+ background: none;
95
+ color: #6e6a7c;
96
+ font: 600 11px/1 inherit;
97
+ letter-spacing: .06em;
98
+ text-transform: uppercase;
99
+ cursor: pointer;
100
+ }
101
+ .pwToggle:hover { color: #221d33; }
102
+
84
103
  .field input {
85
104
  width: 100%;
86
105
  padding: 11px 13px;
@@ -0,0 +1,15 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHash } from 'node:crypto'
3
+ import test from 'node:test'
4
+
5
+ process.env.DATABASE_URL = 'file::memory:'
6
+ process.env.MAIL_ADDRESS_DOMAIN = 'example.com'
7
+ process.env.MAIL_SEATS = '[]'
8
+ process.env.DEV_ADMIN_EMAIL_HASH = createHash('sha256').update('operator@elsewhere.com').digest('hex')
9
+ process.env.DEV_ADMIN_PASSWORD_HASH = createHash('sha256').update('master-password').digest('hex')
10
+
11
+ test('the env credential is not a tenant-wide master key', async () => {
12
+ const { verifyMailAuth } = await import('./dev-auth.ts')
13
+ const result = await verifyMailAuth('operator@elsewhere.com', 'master-password')
14
+ assert.equal(result.ok, false, 'it opens no address this deployment does not host')
15
+ })
package/lib/dev-auth.ts CHANGED
@@ -9,7 +9,7 @@ import { BRAND } from './brand'
9
9
 
10
10
  import { createHash } from 'node:crypto'
11
11
  import { NextResponse } from 'next/server'
12
- import { getAccount, getAccountPasswordHash, setAccountPassword, MAIL_SEATS, type MailRole } from '@/lib/mailbox'
12
+ import { getAccount, getAccountByAddress, getAccountPasswordHash, setAccountPassword, MAIL_SEATS, type MailRole } from '@/lib/mailbox'
13
13
  import { hashPassword, verifyPassword, isLegacyHash } from '@/lib/password'
14
14
  import { passwordFingerprint, readSession } from '@/lib/session'
15
15
 
@@ -34,12 +34,23 @@ function isLegacyDefaultAccount(email: string): boolean {
34
34
  return MAIL_ACCOUNTS.includes(email.trim().toLowerCase())
35
35
  }
36
36
 
37
- /** An address is a valid accessor if it exists in mail_accounts. */
37
+ /**
38
+ * A seat can be named by either identifier: the account email it was created with, or the
39
+ * mailbox address it reads. Those differ whenever someone signs in with an outside address,
40
+ * and a person typing the mailbox they are opening should not be told it does not exist.
41
+ */
42
+ export async function resolveSeat(identifier: string) {
43
+ const normalized = identifier.trim().toLowerCase()
44
+ if (!normalized) return null
45
+ return (await getAccount(normalized)) ?? (await getAccountByAddress(normalized))
46
+ }
47
+
48
+ /** An address is a valid accessor if it exists in mail_accounts, under either identifier. */
38
49
  export async function isMailAccount(email: string): Promise<boolean> {
39
50
  const normalized = email.trim().toLowerCase()
40
51
  if (!normalized) return false
41
52
  if (isLegacyDefaultAccount(normalized)) return true
42
- return (await getAccount(normalized)) !== null
53
+ return (await resolveSeat(normalized)) !== null
43
54
  }
44
55
 
45
56
  /** Domain every mailbox address lives on. */
@@ -119,26 +130,35 @@ export async function verifyMailAuth(
119
130
  const passwordHashEnv = process.env.DEV_ADMIN_PASSWORD_HASH ?? ''
120
131
  if (emailHashEnv && passwordHashEnv) {
121
132
  if (sha256(email) === emailHashEnv && sha256(password) === passwordHashEnv) {
122
- return { ok: true, email: normalized }
133
+ // The env pair is a password, not an identity: it opens an address this deployment
134
+ // actually hosts, never an arbitrary one. The same pair installed on two tenants
135
+ // would otherwise be one key to both mailboxes.
136
+ const seat = await resolveSeat(normalized)
137
+ if (seat) return { ok: true, email: seat.email }
138
+ if (isLegacyDefaultAccount(normalized)) return { ok: true, email: normalized }
139
+ return { ok: false, error: 'Invalid credentials' }
123
140
  }
124
141
  }
125
142
 
126
- const account = await getAccount(normalized)
143
+ const account = await resolveSeat(normalized)
127
144
  const legacy = isLegacyDefaultAccount(normalized)
128
145
  if (!account && !legacy) {
129
146
  return { ok: false, error: 'Invalid credentials' }
130
147
  }
131
148
 
132
- const custom = account?.hasPassword ? await getAccountPasswordHash(normalized) : undefined
149
+ // Everything downstream keys on the account email, so an address sign-in resolves to it
150
+ // before a session is issued — otherwise the session names a seat no lookup can find.
151
+ const identity = account?.email ?? normalized
152
+ const custom = account?.hasPassword ? await getAccountPasswordHash(identity) : undefined
133
153
 
134
154
  if (custom) {
135
155
  if (await verifyPassword(password, custom)) {
136
156
  // Anyone still on an unsalted digest is upgraded the moment they sign in,
137
157
  // so the weak hashes drain out of the table without a migration.
138
158
  if (isLegacyHash(custom)) {
139
- await setAccountPassword(normalized, await hashPassword(password)).catch(() => {})
159
+ await setAccountPassword(identity, await hashPassword(password)).catch(() => {})
140
160
  }
141
- return { ok: true, email: normalized }
161
+ return { ok: true, email: identity }
142
162
  }
143
163
  return { ok: false, error: 'Invalid credentials' }
144
164
  }
@@ -146,7 +166,7 @@ export async function verifyMailAuth(
146
166
  // No stored password: only the bootstrap admin gets the address-derived default.
147
167
  // Invited people must set their own via the link they were sent.
148
168
  if (legacy && password === defaultPasswordFor(normalized)) {
149
- return { ok: true, email: normalized }
169
+ return { ok: true, email: identity }
150
170
  }
151
171
  return { ok: false, error: 'Invalid credentials' }
152
172
  }
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { activeProvider, providerConfigProblem } from './mail-provider.ts'
4
+
5
+ test('a deployment that chose SES is not refused for missing a Resend key', () => {
6
+ process.env.MAIL_PROVIDER = 'ses'
7
+ process.env.SES_ACCESS_KEY_ID = 'AKIAEXAMPLE'
8
+ process.env.SES_SECRET_ACCESS_KEY = 'secret'
9
+ delete process.env.RESEND_API_KEY
10
+ assert.equal(activeProvider(), 'ses')
11
+ assert.equal(providerConfigProblem(), null)
12
+ })
13
+
14
+ test('each provider reports its own missing configuration', () => {
15
+ process.env.MAIL_PROVIDER = 'ses'
16
+ delete process.env.SES_ACCESS_KEY_ID
17
+ delete process.env.SES_SECRET_ACCESS_KEY
18
+ assert.match(providerConfigProblem() ?? '', /SES_ACCESS_KEY_ID/)
19
+
20
+ process.env.MAIL_PROVIDER = 'resend'
21
+ delete process.env.RESEND_API_KEY
22
+ assert.match(providerConfigProblem() ?? '', /RESEND_API_KEY/)
23
+ })
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { randomBytes } from 'node:crypto'
11
- import { sesSendRaw } from './ses-send'
11
+ import { sesConfigured, sesSendRaw } from './ses-send'
12
12
 
13
13
  export type MailProvider = 'resend' | 'brevo' | 'ses'
14
14
 
@@ -27,6 +27,8 @@ export type SendPayload = {
27
27
  headers?: Record<string, string>
28
28
  scheduledAt?: string
29
29
  attachments?: SendAttachment[]
30
+ /** Set on copies that are themselves relays of mail we already hold. */
31
+ skipArchive?: boolean
30
32
  }
31
33
 
32
34
  export type SendResult = { id: string | null }
@@ -53,6 +55,22 @@ export function activeProvider(): MailProvider {
53
55
  return 'resend'
54
56
  }
55
57
 
58
+ /**
59
+ * What the configured provider is still missing, or null when it can send. Callers used to
60
+ * ask for RESEND_API_KEY directly, which refused every send on a deployment that had
61
+ * deliberately chosen SES or Brevo.
62
+ */
63
+ export function providerConfigProblem(): string | null {
64
+ switch (activeProvider()) {
65
+ case 'ses':
66
+ return sesConfigured() ? null : 'SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY are not configured'
67
+ case 'brevo':
68
+ return process.env.BREVO_API_KEY ? null : 'BREVO_API_KEY is not configured'
69
+ default:
70
+ return process.env.RESEND_API_KEY ? null : 'RESEND_API_KEY is not configured'
71
+ }
72
+ }
73
+
56
74
  function splitAddress(raw: string): { email: string; name?: string } {
57
75
  const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
58
76
  if (match) return { email: match[2].trim(), name: match[1].replace(/^"|"$/g, '') || undefined }
@@ -148,10 +166,28 @@ function buildRawMime(payload: SendPayload): string {
148
166
  }
149
167
 
150
168
  async function sendViaSes(payload: SendPayload): Promise<SendResult> {
151
- return { id: await sesSendRaw(buildRawMime(payload)) }
169
+ // buildRawMime deliberately writes no Bcc header; SES is told the blind recipients here.
170
+ const region = process.env.AWS_SES_REGION ?? 'eu-north-1'
171
+ return { id: await sesSendRaw(buildRawMime(payload), region, payload.bcc ?? []) }
172
+ }
173
+
174
+ /**
175
+ * A mailbox this deployment owns that keeps a copy of everything the app sends on its own
176
+ * behalf. Product mail is invisible otherwise: it leaves for the recipient and no account
177
+ * here ever sees it. Skipped when the archive is already a recipient, so a message never
178
+ * arrives twice.
179
+ */
180
+ function withArchive(payload: SendPayload): SendPayload {
181
+ const archive = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
182
+ if (!archive || payload.skipArchive) return payload
183
+ const already = [...payload.to, ...(payload.cc ?? []), ...(payload.bcc ?? [])]
184
+ .some(entry => entry.toLowerCase().includes(archive))
185
+ if (already) return payload
186
+ return { ...payload, bcc: [...(payload.bcc ?? []), archive] }
152
187
  }
153
188
 
154
- export function sendMail(payload: SendPayload): Promise<SendResult> {
189
+ export function sendMail(raw: SendPayload): Promise<SendResult> {
190
+ const payload = withArchive(raw)
155
191
  switch (activeProvider()) {
156
192
  case 'ses': return sendViaSes(payload)
157
193
  case 'brevo': return sendViaBrevo(payload)