@novacraft-engineering/mailbox 0.4.5 → 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.
@@ -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
 
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. */
@@ -122,27 +133,32 @@ export async function verifyMailAuth(
122
133
  // The env pair is a password, not an identity: it opens an address this deployment
123
134
  // actually hosts, never an arbitrary one. The same pair installed on two tenants
124
135
  // would otherwise be one key to both mailboxes.
125
- if (await isMailAccount(normalized)) return { ok: true, email: normalized }
136
+ const seat = await resolveSeat(normalized)
137
+ if (seat) return { ok: true, email: seat.email }
138
+ if (isLegacyDefaultAccount(normalized)) return { ok: true, email: normalized }
126
139
  return { ok: false, error: 'Invalid credentials' }
127
140
  }
128
141
  }
129
142
 
130
- const account = await getAccount(normalized)
143
+ const account = await resolveSeat(normalized)
131
144
  const legacy = isLegacyDefaultAccount(normalized)
132
145
  if (!account && !legacy) {
133
146
  return { ok: false, error: 'Invalid credentials' }
134
147
  }
135
148
 
136
- 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
137
153
 
138
154
  if (custom) {
139
155
  if (await verifyPassword(password, custom)) {
140
156
  // Anyone still on an unsalted digest is upgraded the moment they sign in,
141
157
  // so the weak hashes drain out of the table without a migration.
142
158
  if (isLegacyHash(custom)) {
143
- await setAccountPassword(normalized, await hashPassword(password)).catch(() => {})
159
+ await setAccountPassword(identity, await hashPassword(password)).catch(() => {})
144
160
  }
145
- return { ok: true, email: normalized }
161
+ return { ok: true, email: identity }
146
162
  }
147
163
  return { ok: false, error: 'Invalid credentials' }
148
164
  }
@@ -150,7 +166,7 @@ export async function verifyMailAuth(
150
166
  // No stored password: only the bootstrap admin gets the address-derived default.
151
167
  // Invited people must set their own via the link they were sent.
152
168
  if (legacy && password === defaultPasswordFor(normalized)) {
153
- return { ok: true, email: normalized }
169
+ return { ok: true, email: identity }
154
170
  }
155
171
  return { ok: false, error: 'Invalid credentials' }
156
172
  }
@@ -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 }
@@ -164,10 +166,28 @@ function buildRawMime(payload: SendPayload): string {
164
166
  }
165
167
 
166
168
  async function sendViaSes(payload: SendPayload): Promise<SendResult> {
167
- 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 ?? []) }
168
172
  }
169
173
 
170
- export function sendMail(payload: SendPayload): Promise<SendResult> {
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] }
187
+ }
188
+
189
+ export function sendMail(raw: SendPayload): Promise<SendResult> {
190
+ const payload = withArchive(raw)
171
191
  switch (activeProvider()) {
172
192
  case 'ses': return sendViaSes(payload)
173
193
  case 'brevo': return sendViaBrevo(payload)