@novacraft-engineering/mailbox 0.4.4 → 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.
- package/app/api/mail/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- package/app/api/mail/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- package/app/api/mail/send/route.ts +4 -4
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/page.module.css +59 -0
- package/app/mail/page.tsx +265 -52
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +5 -1
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +17 -1
- package/lib/mailbox.ts +64 -9
- package/lib/receive.test.ts +13 -0
- package/lib/receive.ts +8 -5
- package/lib/share-ticket.ts +42 -0
- package/package.json +1 -1
package/app/share/[id]/page.tsx
CHANGED
|
@@ -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
|
-
//
|
|
84
|
-
// and
|
|
85
|
-
|
|
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
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/lib/mail-provider.ts
CHANGED
|
@@ -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
|
@@ -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(
|
|
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
|
|
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
|
|
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`
|
|
@@ -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
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
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",
|