@novacraft-engineering/mailbox 0.4.3 → 0.4.5

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.
@@ -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
@@ -119,7 +119,11 @@ export async function verifyMailAuth(
119
119
  const passwordHashEnv = process.env.DEV_ADMIN_PASSWORD_HASH ?? ''
120
120
  if (emailHashEnv && passwordHashEnv) {
121
121
  if (sha256(email) === emailHashEnv && sha256(password) === passwordHashEnv) {
122
- return { ok: true, email: normalized }
122
+ // The env pair is a password, not an identity: it opens an address this deployment
123
+ // actually hosts, never an arbitrary one. The same pair installed on two tenants
124
+ // would otherwise be one key to both mailboxes.
125
+ if (await isMailAccount(normalized)) return { ok: true, email: normalized }
126
+ return { ok: false, error: 'Invalid credentials' }
123
127
  }
124
128
  }
125
129
 
@@ -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
 
@@ -53,6 +53,22 @@ export function activeProvider(): MailProvider {
53
53
  return 'resend'
54
54
  }
55
55
 
56
+ /**
57
+ * What the configured provider is still missing, or null when it can send. Callers used to
58
+ * ask for RESEND_API_KEY directly, which refused every send on a deployment that had
59
+ * deliberately chosen SES or Brevo.
60
+ */
61
+ export function providerConfigProblem(): string | null {
62
+ switch (activeProvider()) {
63
+ case 'ses':
64
+ return sesConfigured() ? null : 'SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY are not configured'
65
+ case 'brevo':
66
+ return process.env.BREVO_API_KEY ? null : 'BREVO_API_KEY is not configured'
67
+ default:
68
+ return process.env.RESEND_API_KEY ? null : 'RESEND_API_KEY is not configured'
69
+ }
70
+ }
71
+
56
72
  function splitAddress(raw: string): { email: string; name?: string } {
57
73
  const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
58
74
  if (match) return { email: match[2].trim(), name: match[1].replace(/^"|"$/g, '') || undefined }
package/lib/mailbox.ts CHANGED
@@ -60,8 +60,8 @@ export type StashItem = {
60
60
  const MAX_INBOX = 500
61
61
  const MAX_EVENTS = 150
62
62
 
63
- /** Turso once it is configured, D1 until then, so the switch needs no redeploy dance. */
64
- const tursoConfigured = () => Boolean(process.env.TURSO_DATABASE_URL)
63
+ /** SQLite/libSQL once it is configured, D1 until then, so the switch needs no redeploy dance. */
64
+ const tursoConfigured = () => Boolean(process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL)
65
65
 
66
66
  function db() {
67
67
  return tursoConfigured() ? turso() : d1()
@@ -342,6 +342,11 @@ export function ensureMailSchema(): Promise<void> {
342
342
  // allowed to fail: the second time round the column is already there.
343
343
  await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN password_is_default INTEGER NOT NULL DEFAULT 0')
344
344
  .catch(() => {})
345
+ // Where a reset link goes when the account's own mailbox is the thing locked.
346
+ await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_email TEXT').catch(() => {})
347
+ await sqlRaw('ALTER TABLE mail_accounts ADD COLUMN recovery_verified INTEGER NOT NULL DEFAULT 0').catch(() => {})
348
+ // A link mailed to an unproven address must not be able to set a password.
349
+ await sqlRaw("ALTER TABLE mail_reset_tokens ADD COLUMN purpose TEXT NOT NULL DEFAULT 'reset'").catch(() => {})
345
350
  await sqlRaw("ALTER TABLE mail_webhook_events ADD COLUMN status TEXT NOT NULL DEFAULT 'working'")
346
351
  .catch(() => {})
347
352
  await sqlRaw('ALTER TABLE mail_sent ADD COLUMN attachments TEXT').catch(() => {})
@@ -1484,6 +1489,9 @@ export type MailAccount = {
1484
1489
  hasPassword: boolean
1485
1490
  createdAt: string | null
1486
1491
  invitedBy: string | null
1492
+ /** Outside address that can receive a reset link. Only usable once proven. */
1493
+ recoveryEmail: string | null
1494
+ recoveryVerified: boolean
1487
1495
  }
1488
1496
 
1489
1497
  function mapAccount(row: Record<string, unknown>): MailAccount {
@@ -1496,27 +1504,29 @@ function mapAccount(row: Record<string, unknown>): MailAccount {
1496
1504
  hasPassword: Boolean(row.has_password),
1497
1505
  createdAt: isoOrNull(row.created_at),
1498
1506
  invitedBy: (row.invited_by as string) ?? null,
1507
+ recoveryEmail: (row.recovery_email as string) ?? null,
1508
+ recoveryVerified: Boolean(Number(row.recovery_verified ?? 0)),
1499
1509
  }
1500
1510
  }
1501
1511
 
1502
1512
  export async function listAccounts(): Promise<MailAccount[]> {
1503
1513
  await ensureMailSchema()
1504
1514
  const sql = db()
1505
- const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
1515
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts ORDER BY created_at ASC`
1506
1516
  return rows.map(mapAccount)
1507
1517
  }
1508
1518
 
1509
1519
  export async function getAccount(email: string): Promise<MailAccount | null> {
1510
1520
  await ensureMailSchema()
1511
1521
  const sql = db()
1512
- const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1522
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1513
1523
  return rows[0] ? mapAccount(rows[0]) : null
1514
1524
  }
1515
1525
 
1516
1526
  export async function getAccountByAddress(address: string): Promise<MailAccount | null> {
1517
1527
  await ensureMailSchema()
1518
1528
  const sql = db()
1519
- const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
1529
+ const rows = await sql`SELECT email, name, address, role, status, invited_by, created_at, recovery_email, recovery_verified, (password_hash IS NOT NULL) AS has_password FROM mail_accounts WHERE lower(address) = ${address.trim().toLowerCase()}`
1520
1530
  return rows[0] ? mapAccount(rows[0]) : null
1521
1531
  }
1522
1532
 
@@ -1552,6 +1562,27 @@ export async function deleteAccount(email: string): Promise<void> {
1552
1562
  await sql`DELETE FROM mail_accounts WHERE email = ${email.trim().toLowerCase()}`
1553
1563
  }
1554
1564
 
1565
+ /** Record a recovery address as claimed but unproven. Re-saving the same one re-arms it. */
1566
+ export async function setRecoveryEmail(email: string, recovery: string | null): Promise<void> {
1567
+ await ensureMailSchema()
1568
+ const sql = db()
1569
+ const normalized = recovery ? recovery.trim().toLowerCase() : null
1570
+ await sql`
1571
+ UPDATE mail_accounts SET recovery_email = ${normalized}, recovery_verified = 0
1572
+ WHERE email = ${email.trim().toLowerCase()}`
1573
+ }
1574
+
1575
+ /** Prove the address: only the one currently on the account, so a stale link cannot land. */
1576
+ export async function markRecoveryVerified(email: string, recovery: string): Promise<boolean> {
1577
+ await ensureMailSchema()
1578
+ const sql = db()
1579
+ const rows = await sql`
1580
+ UPDATE mail_accounts SET recovery_verified = 1
1581
+ WHERE email = ${email.trim().toLowerCase()} AND lower(recovery_email) = ${recovery.trim().toLowerCase()}
1582
+ RETURNING email`
1583
+ return rows.length > 0
1584
+ }
1585
+
1555
1586
  // ── Sent-mail archive (our own copy, independent of any provider) ─
1556
1587
  export type SentMessage = {
1557
1588
  id: string
@@ -1569,6 +1600,8 @@ export type SentMessage = {
1569
1600
  attachments?: Array<{ filename: string; size?: number; contentType?: string; key?: string }>
1570
1601
  /** How many files the list should raise a paperclip for, counted the way the inbox counts. */
1571
1602
  attachmentCount?: number
1603
+ /** Sent by the app itself — an invite, a reset, an auto-reply — rather than by a person. */
1604
+ isAuto?: boolean
1572
1605
  }
1573
1606
 
1574
1607
  export async function recordSentMessage(message: SentMessage): Promise<void> {
@@ -1665,11 +1698,12 @@ export async function readSentArchive(options: {
1665
1698
  sharedAddress?: string | null
1666
1699
  query?: ParsedQuery | null
1667
1700
  limit?: number
1701
+ includeAuto?: boolean
1668
1702
  } = {}): Promise<SentMessage[]> {
1669
1703
  await ensureMailSchema()
1670
1704
  const sql = db()
1671
1705
  const limit = Math.min(Math.max(options.limit ?? 500, 1), 1000)
1672
- const where: string[] = ['coalesce(m.is_auto, 0) = 0']
1706
+ const where: string[] = options.includeAuto ? [] : ['coalesce(m.is_auto, 0) = 0']
1673
1707
  const args: unknown[] = []
1674
1708
 
1675
1709
  const owner = options.ownerAddress?.trim().toLowerCase()
@@ -1691,6 +1725,7 @@ export async function readSentArchive(options: {
1691
1725
  sql,
1692
1726
  `SELECT s.id, s.from_addr, s.to_addrs, s.cc, s.bcc, s.reply_to, s.subject, s.created_at, s.last_event,
1693
1727
  json_array_length(CASE WHEN json_valid(s.attachments) THEN s.attachments ELSE '[]' END) AS attach_count,
1728
+ coalesce(m.is_auto, 0) AS is_auto,
1694
1729
  ${textColumn} AS text
1695
1730
  FROM mail_sent s LEFT JOIN mail_sent_meta m ON m.email_id = s.id
1696
1731
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
@@ -1710,6 +1745,7 @@ export async function readSentArchive(options: {
1710
1745
  createdAt: isoOrNull(row.created_at) ?? new Date(0).toISOString(),
1711
1746
  lastEvent: (row.last_event as string) ?? null,
1712
1747
  attachmentCount: Number(row.attach_count ?? 0),
1748
+ isAuto: Boolean(Number(row.is_auto ?? 0)),
1713
1749
  }))
1714
1750
  }
1715
1751
 
@@ -1931,13 +1967,29 @@ export async function revokeShare(id: string, owner: string): Promise<boolean> {
1931
1967
  return rows.length > 0
1932
1968
  }
1933
1969
 
1934
- export async function createResetToken(email: string, token: string, expires: number): Promise<void> {
1970
+ export async function createResetToken(
1971
+ email: string,
1972
+ token: string,
1973
+ expires: number,
1974
+ purpose: 'reset' | 'verify-recovery' = 'reset',
1975
+ ): Promise<void> {
1935
1976
  await ensureMailSchema()
1936
1977
  const sql = db()
1937
1978
  await sql`DELETE FROM mail_reset_tokens WHERE expires_at < ${nowIso()}`
1938
1979
  await sql`
1939
- INSERT INTO mail_reset_tokens (token, email, expires_at)
1940
- VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()})`
1980
+ INSERT INTO mail_reset_tokens (token, email, expires_at, purpose)
1981
+ VALUES (${token}, ${email.toLowerCase()}, ${new Date(expires).toISOString()}, ${purpose})`
1982
+ }
1983
+
1984
+ /** Spend a non-reset token, returning the address it was issued for. */
1985
+ export async function consumeToken(token: string, purpose: 'verify-recovery'): Promise<string | null> {
1986
+ await ensureMailSchema()
1987
+ const sql = db()
1988
+ const rows = await sql`
1989
+ DELETE FROM mail_reset_tokens
1990
+ WHERE token = ${token} AND purpose = ${purpose} AND expires_at > ${nowIso()}
1991
+ RETURNING email`
1992
+ return rows[0]?.email ? String(rows[0].email) : null
1941
1993
  }
1942
1994
 
1943
1995
  /**
@@ -1948,7 +2000,8 @@ export async function resetTokenEmail(token: string): Promise<string | null> {
1948
2000
  await ensureMailSchema()
1949
2001
  const sql = db()
1950
2002
  const rows = await sql`
1951
- SELECT email FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()}`
2003
+ SELECT email FROM mail_reset_tokens
2004
+ WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}`
1952
2005
  const email = rows[0]?.email
1953
2006
  return email ? String(email) : null
1954
2007
  }
@@ -1958,7 +2011,9 @@ export async function resetPasswordWithToken(token: string, passwordHash: string
1958
2011
  await ensureMailSchema()
1959
2012
  const sql = db()
1960
2013
  const consumed = await sql`
1961
- DELETE FROM mail_reset_tokens WHERE token = ${token} AND expires_at > ${nowIso()} RETURNING email`
2014
+ DELETE FROM mail_reset_tokens
2015
+ WHERE token = ${token} AND coalesce(purpose, 'reset') = 'reset' AND expires_at > ${nowIso()}
2016
+ RETURNING email`
1962
2017
  const email = consumed[0]?.email
1963
2018
  if (!email) return null
1964
2019
  await sql`
package/lib/r2.ts CHANGED
@@ -15,17 +15,17 @@ import { createHash, createHmac } from 'node:crypto'
15
15
 
16
16
  const SERVICE = 's3'
17
17
  // R2 ignores the region but SigV4 requires one in the scope; AWS S3 needs the real one.
18
- const REGION = process.env.R2_REGION ?? process.env.S3_REGION ?? 'auto'
18
+ const REGION = process.env.S3_REGION ?? process.env.R2_REGION ?? 'auto'
19
19
 
20
20
  type Config = { endpoint: string; bucket: string; accessKeyId: string; secretAccessKey: string }
21
21
 
22
22
  function config(): Config {
23
- const endpoint = process.env.R2_S3_ENDPOINT
24
- const bucket = process.env.R2_BUCKET
25
- const accessKeyId = process.env.R2_ACCESS_KEY_ID
26
- const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
23
+ const endpoint = process.env.S3_ENDPOINT ?? process.env.R2_S3_ENDPOINT
24
+ const bucket = process.env.S3_BUCKET ?? process.env.R2_BUCKET
25
+ const accessKeyId = process.env.S3_ACCESS_KEY_ID ?? process.env.R2_ACCESS_KEY_ID
26
+ const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY ?? process.env.R2_SECRET_ACCESS_KEY
27
27
  if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
28
- throw new Error('R2_S3_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY must be configured')
28
+ throw new Error('S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY must be configured')
29
29
  }
30
30
  return { endpoint: endpoint.replace(/\/+$/, ''), bucket, accessKeyId, secretAccessKey }
31
31
  }
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ process.env.MAIL_ADDRESS_DOMAIN = 'contact.example.com'
5
+ process.env.MAIL_ADDRESS_DOMAINS = 'example.com,contact.example.com'
6
+
7
+ test('every domain we receive on is refused as a forward target, not just the primary one', async () => {
8
+ const { ADDRESS_DOMAINS } = await import('./brand.ts')
9
+ const loops = (address: string) => ADDRESS_DOMAINS.includes(address.split('@')[1] ?? '')
10
+ assert.equal(loops('hosting@example.com'), true, 'a secondary domain we receive on would loop')
11
+ assert.equal(loops('hello@contact.example.com'), true, 'the primary domain would loop')
12
+ assert.equal(loops('someone@gmail.com'), false, 'an outside address is a real destination')
13
+ })
package/lib/receive.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BRAND } from '@/lib/brand'
1
+ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
2
  import { sendPush } from '@/lib/push'
3
3
  import { stripOwnPixel } from '@/lib/email-html'
4
4
  import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
@@ -160,10 +160,13 @@ export async function forwardToAccounts(
160
160
  const owningAccount = ownerAddress ? await getAccountByAddress(ownerAddress) : null
161
161
  const recipient = owningAccount?.email ?? FORWARD_RECIPIENTS[0] // fallback: the admin
162
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
163
+ // Forwarding exists to put a copy somewhere the recipient already reads. Once a domain's
164
+ // MX points here, an address on it is somewhere we read — so forwarding to one would
165
+ // deliver back into this handler and forward again, without end. Every domain this
166
+ // deployment receives counts, not just the primary one: an account on a secondary domain
167
+ // forwards to itself once a minute until something outside gives up.
168
+ const recipientDomain = recipient.trim().toLowerCase().split('@')[1] ?? ''
169
+ if (recipientDomain === MAIL_DOMAIN || ADDRESS_DOMAINS.includes(recipientDomain)) return
167
170
  const from = MAIL_FROM
168
171
  // Attachment bytes only come from Resend's receiving API; under another provider the
169
172
  // forward still goes out, just without re-attaching the files.
@@ -0,0 +1,42 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+
3
+ /**
4
+ * A short-lived permission to fetch one shared file through this domain.
5
+ *
6
+ * The recipient is not a mailbox holder, so there is no session to lean on. The share id
7
+ * is the capability and the password is the second factor, but both are checked once, on
8
+ * the page. This ticket carries that decision the few seconds to the download itself, so
9
+ * the file can be streamed from our own host rather than handing the browser a link to
10
+ * the bucket — a second hostname the recipient's network may not be able to reach.
11
+ */
12
+ const TTL_SECONDS = 15 * 60
13
+
14
+ function secret(): string | null {
15
+ return process.env.MAIL_SESSION_SECRET?.trim() || null
16
+ }
17
+
18
+ function sign(payload: string, key: string): string {
19
+ return createHmac('sha256', key).update(payload).digest('base64url')
20
+ }
21
+
22
+ export function issueShareTicket(id: string): string | null {
23
+ const key = secret()
24
+ if (!key) return null
25
+ const payload = `${id}.${Date.now() + TTL_SECONDS * 1000}`
26
+ return `${payload}.${sign(payload, key)}`
27
+ }
28
+
29
+ /** True only for a ticket this server signed, for this share, that has not expired. */
30
+ export function shareTicketValid(ticket: string | null, id: string): boolean {
31
+ const key = secret()
32
+ if (!key || !ticket) return false
33
+ const cut = ticket.lastIndexOf('.')
34
+ if (cut < 1) return false
35
+ const payload = ticket.slice(0, cut)
36
+ const given = ticket.slice(cut + 1)
37
+ const expected = sign(payload, key)
38
+ if (given.length !== expected.length) return false
39
+ if (!timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return false
40
+ const [signedId, expires] = payload.split('.')
41
+ return signedId === id && Number(expires) > Date.now()
42
+ }
package/lib/turso.ts CHANGED
@@ -15,9 +15,9 @@ let cached: Client | null = null
15
15
 
16
16
  function client(): Client {
17
17
  if (cached) return cached
18
- const url = process.env.TURSO_DATABASE_URL
19
- const authToken = process.env.TURSO_AUTH_TOKEN
20
- if (!url) throw new Error('TURSO_DATABASE_URL must be configured')
18
+ const url = process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL
19
+ const authToken = process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN
20
+ if (!url) throw new Error('DATABASE_URL must be configured')
21
21
  // Embedded replicas and file: URLs need no token; a remote libsql:// URL does.
22
22
  cached = createClient({ url, ...(authToken ? { authToken } : {}) })
23
23
  return cached
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -1,9 +1,5 @@
1
1
  # Brand assets
2
2
 
3
- > **Never deploy or `git push` unless asked for it in that request.** Pushing
4
- > `master` auto-deploys the Novacraft mailbox. See the deploying section in the
5
- > root [README](../README.md).
6
-
7
3
  **No tenant's artwork belongs in this repository.** Each mailbox keeps its own
8
4
  images on its mounted volume, in the directory named by `BRAND_ASSET_DIR`
9
5
  (default `/data/brand`), and the app serves them from `/brand/<file>`.
@@ -20,7 +20,7 @@ const args = process.argv.slice(2)
20
20
  const at = args.indexOf('--batch')
21
21
  const BATCH = at === -1 ? 40 : Number(args[at + 1])
22
22
 
23
- const db = createClient({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN })
23
+ const db = createClient({ url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL, authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN })
24
24
  const apiKey = process.env.RESEND_API_KEY
25
25
 
26
26
  async function retry(work) {
@@ -24,8 +24,8 @@ const at = args.indexOf('--batch')
24
24
  const BATCH = at === -1 ? 500 : Number(args[at + 1])
25
25
 
26
26
  const db = createClient({
27
- url: process.env.TURSO_DATABASE_URL,
28
- authToken: process.env.TURSO_AUTH_TOKEN,
27
+ url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
28
+ authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
29
29
  })
30
30
 
31
31
  const UPDATE = `UPDATE mail_inbox SET
@@ -24,8 +24,8 @@ const at = args.indexOf('--batch')
24
24
  const BATCH = at === -1 ? 40 : Number(args[at + 1])
25
25
 
26
26
  const db = createClient({
27
- url: process.env.TURSO_DATABASE_URL,
28
- authToken: process.env.TURSO_AUTH_TOKEN,
27
+ url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
28
+ authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
29
29
  })
30
30
 
31
31
  /** Ids are ours, but these go into string-interpolated SQL: anything unexpected is left alone. */
@@ -20,8 +20,8 @@ for (const line of readFileSync(new URL('../.env.local', import.meta.url), 'utf8
20
20
  }
21
21
 
22
22
  const db = createClient({
23
- url: process.env.TURSO_DATABASE_URL,
24
- authToken: process.env.TURSO_AUTH_TOKEN,
23
+ url: process.env.DATABASE_URL ?? process.env.TURSO_DATABASE_URL,
24
+ authToken: process.env.DATABASE_AUTH_TOKEN ?? process.env.TURSO_AUTH_TOKEN,
25
25
  })
26
26
 
27
27
  const DEV_ADDRESS = 'test@example.com'
@@ -103,7 +103,7 @@ async function main() {
103
103
  // so opening an attachment in the seeded mailbox opens something. Without a bucket
104
104
  // configured the records are written on their own, as they always were.
105
105
  const { buildSamples, buildHeavySamples } = await import('./sample-files.mjs')
106
- const bucketReady = Boolean(process.env.R2_BUCKET && process.env.R2_ACCESS_KEY_ID && process.env.R2_S3_ENDPOINT)
106
+ const bucketReady = Boolean((process.env.S3_BUCKET ?? process.env.R2_BUCKET) && (process.env.S3_ACCESS_KEY_ID ?? process.env.R2_ACCESS_KEY_ID) && (process.env.S3_ENDPOINT ?? process.env.R2_S3_ENDPOINT))
107
107
  let smallFiles = []
108
108
  let heavyFiles = []
109
109
  if (bucketReady) {
package/tools/README.md CHANGED
@@ -1,9 +1,5 @@
1
1
  # Tools
2
2
 
3
- > **Never deploy or `git push` unless asked for it in that request.** Pushing
4
- > `master` auto-deploys the Novacraft mailbox. See the deploying section in the
5
- > root [README](../README.md).
6
-
7
3
  | File | What it does |
8
4
  |---|---|
9
5
  | `import-mbox.py` | Import an mbox into a mailbox. Bodies and metadata first, attachments second. |
@@ -33,7 +33,7 @@ def load_env(path):
33
33
 
34
34
 
35
35
  def query(sql, args=None, attempts=9):
36
- host = os.environ['TURSO_DATABASE_URL'].replace('libsql://', 'https://').rstrip('/')
36
+ host = (os.environ.get('DATABASE_URL') or os.environ['TURSO_DATABASE_URL']).replace('libsql://', 'https://').rstrip('/')
37
37
  stmt = {'sql': sql}
38
38
  if args:
39
39
  stmt['args'] = [{'type': 'text', 'value': str(a)} for a in args]
@@ -42,7 +42,7 @@ def query(sql, args=None, attempts=9):
42
42
  for attempt in range(attempts):
43
43
  try:
44
44
  request = urllib.request.Request(host + '/v2/pipeline', body, {
45
- 'Authorization': 'Bearer ' + os.environ['TURSO_AUTH_TOKEN'],
45
+ 'Authorization': 'Bearer ' + (os.environ.get('DATABASE_AUTH_TOKEN') or os.environ['TURSO_AUTH_TOKEN']),
46
46
  'Content-Type': 'application/json'})
47
47
  with urllib.request.urlopen(request, timeout=180) as response:
48
48
  payload = json.load(response)