@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.
- package/README.md +17 -0
- 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/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- 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/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +39 -7
- package/app/api/mail/threads/route.ts +1 -1
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +254 -1
- package/app/mail/page.tsx +539 -87
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- 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 +29 -9
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +39 -3
- package/lib/mailbox.ts +578 -51
- package/lib/receive.test.ts +25 -0
- package/lib/receive.ts +117 -17
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/lib/share-ticket.ts +42 -0
- package/package.json +1 -1
- 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 => {
|
package/app/mail/search.ts
CHANGED
|
@@ -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/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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
159
|
+
await setAccountPassword(identity, await hashPassword(password)).catch(() => {})
|
|
140
160
|
}
|
|
141
|
-
return { ok: true, email:
|
|
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:
|
|
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
|
+
})
|
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
|
|
|
@@ -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
|
-
|
|
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(
|
|
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)
|