ReviewOS

also looking at this

mail-os/mail

feat: mailbox recovery addresses and password reset by email

#13
Merged chris wants to merge feat/mailbox-recovery into main
21 files +1503 -10

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

AGENTS.mdmodified+1-0
Changes to AGENTS.md
@@ -208,6 +208,7 @@ Test binaries link against `sqlite3` system library. The argon2 password tests u
208208## Common Tasks
209209
210210- **Add a user**: `mail-server user add <email> <password>` (on server)
211- **Recovery address** (where "Forgot password?" sends the reset link): `mail-server user:local recovery <mailbox> [<address>] [--policy initial|managed] [--clear]`; see WEBMAIL.md "Password recovery"
211212- **Check service**: `systemctl status mail` (on server via SSM)
212213- **View IMAP logs**: `journalctl -u mail | grep IMAP`
213214- **Backup**: `mail-server backup create` → S3 bucket `stacks-production-s3-backups`
WEBMAIL.mdmodified+48-0
Changes to WEBMAIL.md
@@ -30,6 +30,46 @@ webmail provisioning, aliases, certificate issuance and renewal live in ts-cloud
3030 Outbox records delivery results. A partial retry contains only failed recipients.
3131 Interrupted dispatch is marked Unknown and requires review before a manual retry.
3232
33## Password recovery
34
35Each mailbox may have one recovery address outside itself. "Forgot password?"
36on the sign-in page (`/forgot`) asks for the mailbox address and always answers
37the same way, whether or not the mailbox exists or has a recovery address. When
38it does, the server mails a reset link to the recovery address, from
39`no-reply@<mailbox domain>` through the normal delivery path, so the message is
40DKIM-signed with that domain's key. The link (`/reset#token=...`) works once,
41expires after 30 minutes, and carries its token in the fragment so it never
42reaches a server or gateway log. Setting the new password signs out every
43session and tells the recovery address that the password was reset.
44
45Limits: at most 3 links per mailbox per hour and one per minute (counted in the
46database, so a restart does not reset them), plus a per-IP limit on requests and
47on token attempts. A link is voided by a newer one, by any password change and
48by any change of the recovery address. Only a token's SHA-256 is stored
49(`mailbox_reset_tokens`).
50
51Links point at `mail.<mailbox domain>` or the server hostname when the request
52came in on one of those names, and at `SMTP_HOSTNAME` otherwise. The request's
53Host header alone is never trusted, because a link built from it would carry the
54token to whichever server the requester named.
55
56Signed-in users see and change their recovery address under Account; saving
57asks for the mailbox password again, and the previous address is notified.
58Operators and deploy provisioning use the CLI:
59
60```bash
61mail user:local recovery pawel@example.com # show
62mail user:local recovery pawel@example.com pawel@elsewhere.example # set (policy managed)
63mail user:local recovery pawel@example.com pawel@elsewhere.example --policy initial
64mail user:local recovery pawel@example.com --clear
65```
66
67`users.recovery_source` records who set the address: `operator` (CLI) or
68`user` (webmail). `--policy initial` sets it unless the owner has set or cleared
69their own; `--policy managed` always sets it. The command prints one
70`Recovery: updated|unchanged|kept|cleared` line and exits non-zero for an
71unknown mailbox or an unusable address.
72
3373## Configuration
3474
3575Declare `infrastructure.compute.managedServices.mail.webmail` in a ts-cloud
@@ -80,6 +120,14 @@ revokes existing webmail sessions.
80120 `delaySeconds` controls the hold. Replaying the same ID cannot dispatch twice.
81121 `GET /webmail/api/outbox` exposes results; `DELETE /webmail/api/outbox/:id`
82122 cancels a pending send. A reviewed Unknown record may be resolved explicitly.
123- `POST /webmail/auth/forgot` `{address}` always returns `{ok:true}` (429 when
124 the IP is over its limit). `POST /webmail/auth/reset/verify` `{token}` returns
125 `{account}` without spending the token; `POST /webmail/auth/reset`
126 `{token,password}` spends it and sets the password. A dead token is 400
127 `invalid_token`. These three need no session but must be same-origin.
128- `GET /webmail/auth/recovery` returns `{recovery, setBy}`;
129 `PUT /webmail/auth/recovery` `{currentPassword, recovery}` changes it, and an
130 empty `recovery` removes it.
83131- Existing immediate-compose and message endpoints remain compatible with the
84132 native SDK and older clients. New clients use the durable draft and queue APIs.
85133
packages/webmail/build.test.tsmodified+10-2
Changes to packages/webmail/build.test.ts
@@ -9,13 +9,13 @@ test('deployments fingerprint styles so existing browsers fetch updated controls
99 expect(stylesheetUrl(first)).toBe(stylesheetUrl(first))
1010 expect(stylesheetUrl(first)).not.toBe(stylesheetUrl(second))
1111 await buildWebmail()
12 for (const page of ['index', 'login']) {
12 for (const page of ['index', 'login', 'recover']) {
1313 const html = await Bun.file(join(import.meta.dir, `../zig/src/api/webmail_dist/${page}.html`)).text()
1414 expect(html).toMatch(/href="\/styles\.css\?v=[0-9a-f]{16}"/)
1515 }
1616})
1717
18for (const page of ['login', 'index']) {
18for (const page of ['login', 'index', 'recover']) {
1919 test(`${page} compiles with STX bindings and a self-contained runtime`, async () => {
2020 const path = join(import.meta.dir, `pages/${page}.stx`)
2121 const source = await Bun.file(path).text()
@@ -31,6 +31,14 @@ for (const page of ['login', 'index']) {
3131 if (page === 'index') {
3232 expect(html).toContain('sandbox=""')
3333 expect(html).toContain('/webmail/auth/password')
34 expect(html).toContain('/webmail/auth/recovery')
35 }
36 if (page === 'login')
37 expect(html).toContain('href="/forgot"')
38 if (page === 'recover') {
39 expect(html).toContain('/webmail/auth/forgot')
40 expect(html).toContain('/webmail/auth/reset/verify')
41 expect(html).toContain('/webmail/auth/reset')
3442 }
3543 })
3644}
packages/webmail/build.tsmodified+1-1
Changes to packages/webmail/build.ts
@@ -13,7 +13,7 @@ export async function buildWebmail(): Promise<void> {
1313 const dist = join(root, '../zig/src/api/webmail_dist')
1414 const styles = await readFile(join(root, 'public/styles.css'))
1515 await mkdir(dist, { recursive: true })
16 for (const page of ['index', 'login']) {
16 for (const page of ['index', 'login', 'recover']) {
1717 const html = await renderTemplate(join(root, `pages/${page}.stx`), { injectCSS: true })
1818 if (!html.includes('data-stx') || html.includes('<script client>'))
1919 throw new Error(`STX did not compile ${page}`)
packages/webmail/functions/recovery.tsadded+48-0
Changes to packages/webmail/functions/recovery.ts
@@ -0,0 +1,48 @@
1/**
2 * Password reset and recovery address helpers shared by the reset page and
3 * the settings dialog. Pure functions, so they are tested without a browser.
4 */
5
6/** A reset token is 32 bytes as 64 lowercase hex characters. */
7const TOKEN = /^[0-9a-f]{64}$/
8
9/**
10 * The token from a reset link's fragment (`#token=<hex>`). The link carries
11 * it in the fragment so it never reaches a server log; anything that is not a
12 * well-formed token reads as none.
13 */
14export function tokenFromHash(hash: string): string {
15 const params = new URLSearchParams(hash.replace(/^#/, ''))
16 const token = (params.get('token') || '').trim().toLowerCase()
17 return TOKEN.test(token) ? token : ''
18}
19
20export const MIN_PASSWORD_LENGTH = 12
21
22/** Why a new password cannot be submitted, or '' when it can. */
23export function newPasswordProblem(password: string, confirmation: string): string {
24 if (password.length < MIN_PASSWORD_LENGTH) return `Use at least ${MIN_PASSWORD_LENGTH} characters.`
25 if (password.length > 1024) return 'Use at most 1024 characters.'
26 if (password !== confirmation) return 'The new passwords do not match.'
27 return ''
28}
29
30/**
31 * Why a recovery address cannot be saved for `mailbox`, or '' when it can.
32 * An empty address is allowed: it removes the recovery address. The server
33 * applies the same rules; this only answers sooner.
34 */
35export function recoveryAddressProblem(mailbox: string, address: string): string {
36 const value = address.trim()
37 if (!value) return ''
38 if (!/^[^\s@,;:"<>\\/]+@[^\s@,;:"<>\\/]+\.[^\s@,;:"<>\\/]+$/.test(value)) return 'Enter a valid email address.'
39 if (value.toLowerCase() === mailbox.trim().toLowerCase()) return 'Use an address outside this mailbox.'
40 return ''
41}
42
43/** The message for a failed reset request, by HTTP status. */
44export function resetErrorMessage(status: number, fallback = 'Something went wrong. Please try again.'): string {
45 if (status === 429) return 'Too many attempts. Please wait a few minutes.'
46 if (status === 400) return 'This reset link is invalid, already used or expired. Ask for a new one.'
47 return fallback
48}
packages/webmail/functions/use-recovery-settings.tsadded+64-0
Changes to packages/webmail/functions/use-recovery-settings.ts
@@ -0,0 +1,64 @@
1import type { createMailApi } from './mail-api'
2import { state } from '@stacksjs/stx'
3import { recoveryAddressProblem } from './recovery'
4
5/** What GET/PUT /webmail/auth/recovery return. */
6export interface RecoveryState {
7 recovery: string | null
8 /** Who set it: the mailbox owner, or the operator (deploy configuration). */
9 setBy: 'user' | 'operator' | null
10}
11
12type Api = ReturnType<typeof createMailApi>
13
14/**
15 * The recovery address section of account settings. Saving asks for the
16 * mailbox password again: an open session alone must not be enough to send
17 * the account's reset links somewhere else.
18 */
19export function useRecoverySettings(api: Api, mailbox: () => string) {
20 const recoveryAddress = state<string | null>(null)
21 const recoverySetBy = state<RecoveryState['setBy']>(null)
22 const recoveryInput = state('')
23 const recoveryPassword = state('')
24 const recoveryError = state('')
25 const recoveryNotice = state('')
26 const recoveryLoading = state(false)
27 const savingRecovery = state(false)
28
29 function install(result: RecoveryState): void {
30 recoveryAddress.set(result.recovery)
31 recoverySetBy.set(result.setBy)
32 recoveryInput.set(result.recovery || '')
33 }
34
35 async function loadRecovery(): Promise<void> {
36 recoveryError.set('')
37 recoveryNotice.set('')
38 recoveryPassword.set('')
39 recoveryLoading.set(true)
40 try { install(await api<RecoveryState>('/webmail/auth/recovery')) }
41 catch (cause) { recoveryError.set(cause instanceof Error ? cause.message : 'Could not load the recovery address.') }
42 finally { recoveryLoading.set(false) }
43 }
44
45 async function saveRecovery(): Promise<void> {
46 if (savingRecovery()) return
47 recoveryError.set('')
48 recoveryNotice.set('')
49 const wanted = recoveryInput().trim()
50 const problem = recoveryAddressProblem(mailbox(), wanted)
51 if (problem) { recoveryError.set(problem); return }
52 if (!recoveryPassword()) { recoveryError.set('Enter your current password to change the recovery address.'); return }
53 savingRecovery.set(true)
54 try {
55 install(await api<RecoveryState>('/webmail/auth/recovery', { method: 'PUT', body: JSON.stringify({ currentPassword: recoveryPassword(), recovery: wanted }) }))
56 recoveryPassword.set('')
57 recoveryNotice.set(wanted ? 'Recovery address saved.' : 'Recovery address removed. Password reset by email is off for this mailbox.')
58 }
59 catch (cause) { recoveryError.set(cause instanceof Error ? cause.message : 'Could not save the recovery address.') }
60 finally { savingRecovery.set(false) }
61 }
62
63 return { recoveryAddress, recoverySetBy, recoveryInput, recoveryPassword, recoveryError, recoveryNotice, recoveryLoading, savingRecovery, loadRecovery, saveRecovery }
64}
packages/webmail/functions/use-webmail.tsmodified+4-2
Changes to packages/webmail/functions/use-webmail.ts
@@ -5,6 +5,7 @@ import { dateBoundary, folderName, fullDate, messageKey, rangeSelection, readabl
55import { useAttachments } from './use-attachments'
66import { useDraftAutosave } from './use-draft-autosave'
77import { useRecipientEditor } from './use-recipient-editor'
8import { useRecoverySettings } from './use-recovery-settings'
89
910interface Ref<T> { current: T | null }
1011export interface WebmailPorts {
@@ -110,6 +111,7 @@ export function useWebmail(ports: WebmailPorts) {
110111 }
111112 const api = createMailApi(() => user()?.username, onAuthError)
112113 const recipientEditor = useRecipientEditor(value => api<Contact[]>(`/webmail/api/contacts?q=${encodeURIComponent(value)}`))
114 const recoverySettings = useRecoverySettings(api, () => user()?.username || '')
113115 const attachmentEditor = useAttachments(() => user()?.username, limits, cause => { if (cause instanceof MailApiError && cause.status === 401) onAuthError(cause) }, id => api(`/webmail/api/uploads/${id}`, { method: 'DELETE' }))
114116 const hasComposition = derived(() => Boolean(subject() || body() || draftHtml() || attachmentEditor.attachments().length || recipientEditor.toRecipients().length || recipientEditor.ccRecipients().length || recipientEditor.bccRecipients().length || recipientEditor.toInput() || recipientEditor.ccInput() || recipientEditor.bccInput()))
115117 function content() {
@@ -547,7 +549,7 @@ export function useWebmail(ports: WebmailPorts) {
547549 try { if (hasComposition()) await draft.flushAll(); await api('/webmail/auth/logout', { method: 'POST', body: '{}' }); resetComposition(); ports.navigate('/login', { reload: true }) }
548550 catch (cause) { error.set(errorMessage(cause)) }
549551 }
550 function showSettings(): void { settings.set(true); passwordError.set(''); ports.nextTick(() => { if (!ports.password.current?.open) ports.password.current?.showModal() }) }
552 function showSettings(): void { settings.set(true); passwordError.set(''); void recoverySettings.loadRecovery(); ports.nextTick(() => { if (!ports.password.current?.open) ports.password.current?.showModal() }) }
551553 async function changePassword(): Promise<void> {
552554 if (savingPassword()) return
553555 passwordError.set('')
@@ -604,7 +606,7 @@ export function useWebmail(ports: WebmailPorts) {
604606 }
605607 function preventUnload(event: BeforeUnloadEvent): void { if (hasUnsavedChanges()) { event.preventDefault(); event.returnValue = '' } }
606608 function destroy(): void { destroyed = true; ++listRequest; ++messageRequest; recipientEditor.destroy(); attachmentEditor.destroy(); draft.destroy(); selectionEffect() }
607 return { user, folders, folder, messages, threads, expandedThreads, selected, loading, reading, readingLoading, mutating, error, notice, query, activeQuery, searchAll, filterSender, filterAfter, filterBefore, filterUnread, filterFlagged, filterAttachments, filtersOpen, filterActive, conversations, page, total, matchingTotal, moveTo, showHtml, checked, checkedKeys, selectedCount, allChecked, someChecked, undoAction, undoRemaining, pendingSend, sendRemaining, outbox, outboxCount, outboxView, cancelling, authExpired, currentFolderName, now, composing, title, subject, body, showCc, showBcc, sending, sendUncertain, composeError, hasComposition, hasUnsavedChanges, saveStatus, savingDraft: draft.saving, draftError: draft.error, draftConflict: draft.conflict, sendDelay, sendDelayOptions, settings, currentPassword, newPassword, confirmPassword, passwordError, savingPassword, confirmation, confirming, limits, ...recipientEditor, ...attachmentEditor, refresh, loadMessages, selectFolder, search, clearSearch, changeConversationView, turnPage, toggleThread, toggleSelection, toggleAll, toggleThreadSelection, threadChecked, threadPartial, clearSelection, openMessage, closeReader, bulkAction, selectedAction, undoLastAction, compose, closeComposer, editDraft, saveDraft, attachFiles, dropAttachments, saveSendPreference, send, confirmPendingSend, showOutbox, refreshOutbox, cancelSend, retryOutbox, signOut, showSettings, changePassword, showConfirmation, confirmAction, saveDraftCopy: draft.saveCopy, mount, resume, tick, preventUnload, destroy, messageKey, folderName, shortDate, fullDate, readableBytes, safeHtml, attachmentPath }
609 return { user, folders, folder, messages, threads, expandedThreads, selected, loading, reading, readingLoading, mutating, error, notice, query, activeQuery, searchAll, filterSender, filterAfter, filterBefore, filterUnread, filterFlagged, filterAttachments, filtersOpen, filterActive, conversations, page, total, matchingTotal, moveTo, showHtml, checked, checkedKeys, selectedCount, allChecked, someChecked, undoAction, undoRemaining, pendingSend, sendRemaining, outbox, outboxCount, outboxView, cancelling, authExpired, currentFolderName, now, composing, title, subject, body, showCc, showBcc, sending, sendUncertain, composeError, hasComposition, hasUnsavedChanges, saveStatus, savingDraft: draft.saving, draftError: draft.error, draftConflict: draft.conflict, sendDelay, sendDelayOptions, settings, currentPassword, newPassword, confirmPassword, passwordError, savingPassword, confirmation, confirming, limits, ...recipientEditor, ...attachmentEditor, ...recoverySettings, refresh, loadMessages, selectFolder, search, clearSearch, changeConversationView, turnPage, toggleThread, toggleSelection, toggleAll, toggleThreadSelection, threadChecked, threadPartial, clearSelection, openMessage, closeReader, bulkAction, selectedAction, undoLastAction, compose, closeComposer, editDraft, saveDraft, attachFiles, dropAttachments, saveSendPreference, send, confirmPendingSend, showOutbox, refreshOutbox, cancelSend, retryOutbox, signOut, showSettings, changePassword, showConfirmation, confirmAction, saveDraftCopy: draft.saveCopy, mount, resume, tick, preventUnload, destroy, messageKey, folderName, shortDate, fullDate, readableBytes, safeHtml, attachmentPath }
608610}
609611
610612function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }
packages/webmail/pages/index.stxmodified+5-2
Changes to packages/webmail/pages/index.stx
@@ -24,6 +24,8 @@ const {
2424 composing, title, subject, body, showCc, showBcc, sending, sendUncertain, composeError, hasComposition, saveStatus,
2525 savingDraft, draftError, draftConflict, sendDelay, sendDelayOptions, settings, currentPassword, newPassword,
2626 confirmPassword, passwordError, savingPassword, confirmation, confirming, limits,
27 recoveryAddress, recoverySetBy, recoveryInput, recoveryPassword, recoveryError, recoveryNotice, recoveryLoading,
28 savingRecovery, saveRecovery,
2729 toRecipients, ccRecipients, bccRecipients, toInput, ccInput, bccInput, recipientFocus, suggestions,
2830 suggestionIndex, recipientSearching, recipientErrors, lookup, chooseContact, removeRecipient,
2931 editRecipient, recipientKeydown, recipientBlur, attachments, dragging, attachmentBytes,
@@ -55,7 +57,7 @@ onUnmounted(() => mail.destroy())
5557 <button class="wm-folder" :class="{ 'wm-folder--active': !outboxView && folder === '*' }" @click="selectFolder('*')" :disabled="mutating">All mail</button>
5658 <button class="wm-folder" :class="{ 'wm-folder--active': outboxView }" @click="showOutbox()"><span>Outbox</span><span :if="outboxCount > 0" class="wm-folder-count" :text="outboxCount"></span></button>
5759 </nav>
58 <div class="wm-user"><span class="wm-user-email" :text="user?.email"></span><button class="wm-logout" @click="showSettings()">Change password</button><button class="wm-logout" @click="signOut()" :disabled="sending || savingDraft">Sign out</button></div>
60 <div class="wm-user"><span class="wm-user-email" :text="user?.email"></span><button class="wm-logout" @click="showSettings()">Account</button><button class="wm-logout" @click="signOut()" :disabled="sending || savingDraft">Sign out</button></div>
5961 </aside>
6062 <section class="wm-workspace">
6163 <div :if="authExpired" class="wm-session" role="alert">Your session expired. <a href="/login" target="_blank" rel="noopener">Sign in again</a>, then return here to save your changes.</div>
@@ -162,7 +164,8 @@ onUnmounted(() => mail.destroy())
162164 <footer class="wm-composer-foot"><button class="wm-send-btn" type="submit" :disabled="sending || sendUncertain || savingDraft || attachmentPending || draftConflict || authExpired" :text="sending ? 'Queuing…' : 'Send'"></button><button class="wm-action" type="button" @click="saveDraft()" :disabled="sending || savingDraft || attachmentPending" :text="savingDraft ? 'Saving…' : 'Save draft'"></button><button class="wm-action" type="button" @click="showConfirmation('discard')" :disabled="sending || savingDraft">Discard</button><label class="wm-send-delay">Undo Send<select x-model.number="sendDelay" @change="saveSendPreference()" :disabled="sending || sendUncertain"><option :for="value in sendDelayOptions" :value="value" :text="value === 0 ? 'Off' : value + ' seconds'"></option></select></label></footer>
163165 </form>
164166</dialog>
165<dialog :if="settings" ref="passwordDialog" class="wm-modal wm-password-dialog" aria-label="Change password" @cancel.prevent="settings.set(false)"><form class="login" @submit.prevent="changePassword()"><h1>Change password</h1><p class="login-sub">Updates your webmail, SMTP and IMAP credentials.</p><label for="currentPassword">Current password</label><input id="currentPassword" type="password" x-model="currentPassword" autocomplete="current-password" required autofocus><label for="newPassword">New password</label><input id="newPassword" type="password" x-model="newPassword" autocomplete="new-password" minlength="12" required><label for="confirmPassword">Confirm password</label><input id="confirmPassword" type="password" x-model="confirmPassword" autocomplete="new-password" minlength="12" required><p :if="passwordError" role="alert" class="login-error" :text="passwordError"></p><button type="submit" :disabled="savingPassword" :text="savingPassword ? 'Saving…' : 'Change password'"></button><button type="button" @click="settings.set(false)" :disabled="savingPassword">Cancel</button></form></dialog>
167<dialog :if="settings" ref="passwordDialog" class="wm-modal wm-password-dialog" aria-label="Account settings" @cancel.prevent="settings.set(false)"><div class="login"><form @submit.prevent="changePassword()"><h1>Change password</h1><p class="login-sub">Updates your webmail, SMTP and IMAP credentials.</p><label for="currentPassword">Current password</label><input id="currentPassword" type="password" x-model="currentPassword" autocomplete="current-password" required autofocus><label for="newPassword">New password</label><input id="newPassword" type="password" x-model="newPassword" autocomplete="new-password" minlength="12" required><label for="confirmPassword">Confirm password</label><input id="confirmPassword" type="password" x-model="confirmPassword" autocomplete="new-password" minlength="12" required><p :if="passwordError" role="alert" class="login-error" :text="passwordError"></p><button type="submit" :disabled="savingPassword" :text="savingPassword ? 'Saving…' : 'Change password'"></button><button type="button" @click="settings.set(false)" :disabled="savingPassword || savingRecovery">Close</button></form>
168<form class="login-recovery" aria-labelledby="recoveryHeading" @submit.prevent="saveRecovery()"><h2 id="recoveryHeading">Recovery address</h2><p class="login-sub" :if="recoveryLoading">Loading…</p><p class="login-sub" :if="!recoveryLoading && recoveryAddress">Password reset links go to <strong :text="recoveryAddress"></strong><span :if="recoverySetBy === 'operator'"> (set by your mail administrator)</span>.</p><p class="login-sub" :if="!recoveryLoading && !recoveryAddress">None set. Without one, a forgotten password can only be reset by your mail administrator.</p><label for="recoveryInput">Recovery email</label><input id="recoveryInput" type="email" x-model.trim="recoveryInput" autocomplete="email" placeholder="you@another-provider.com"><label for="recoveryPassword">Current password</label><input id="recoveryPassword" type="password" x-model="recoveryPassword" autocomplete="current-password"><p :if="recoveryError" role="alert" class="login-error" :text="recoveryError"></p><p :if="recoveryNotice" role="status" class="login-sub" :text="recoveryNotice"></p><button type="submit" class="login-secondary" :disabled="savingRecovery || recoveryLoading" :text="savingRecovery ? 'Saving…' : 'Save recovery address'"></button></form></div></dialog>
166169<dialog :if="confirmation" ref="confirmDialog" class="wm-modal wm-confirm-dialog" aria-label="Confirm action" @cancel.prevent="confirmation.set('')"><div class="login"><h1 :text="confirmation === 'purge' ? 'Delete permanently?' : confirmation === 'reload-draft' ? 'Reload saved draft?' : confirmation === 'retry-unknown' ? 'Review an interrupted delivery?' : 'Discard this draft?'"></h1><p class="login-sub" :text="confirmation === 'purge' ? 'These messages cannot be recovered after deletion.' : confirmation === 'reload-draft' ? 'Your local changes will be replaced with the latest saved version. Save a copy to keep both.' : confirmation === 'retry-unknown' ? 'This message may already have arrived. Check Sent and the delivery result before retrying. A retry could send a duplicate.' : 'Your saved draft will move to Trash. Unsaved changes will be discarded.'"></p><button @click="confirmation.set('')" :disabled="confirming" autofocus>Keep working</button><button @click="confirmAction()" :disabled="confirming" :text="confirming ? 'Working…' : confirmation === 'purge' ? 'Delete permanently' : confirmation === 'reload-draft' ? 'Reload saved version' : confirmation === 'retry-unknown' ? 'I checked Sent; review the message' : 'Discard draft'"></button></div></dialog>
167170</body>
168171</html>
packages/webmail/pages/login.stxmodified+1-0
Changes to packages/webmail/pages/login.stx
@@ -42,6 +42,7 @@ async function signIn() {
4242 <p :if="error" :text="error" class="login-error" role="alert"></p>
4343 <button type="submit" :disabled="busy" :text="busy ? 'Signing in…' : 'Sign in'"></button>
4444 </form>
45 <p class="login-forgot"><a class="login-link" href="/forgot">Forgot password?</a></p>
4546 <noscript>JavaScript is required to use webmail.</noscript>
4647</main>
4748</body>
packages/webmail/pages/recover.stxadded+137-0
Changes to packages/webmail/pages/recover.stx
@@ -0,0 +1,137 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <meta name="referrer" content="no-referrer">
7 <title>Reset password — Mail</title>
8 <link rel="stylesheet" href="/styles.css">
9</head>
10<body>
11<script client>
12import { newPasswordProblem, resetErrorMessage, tokenFromHash } from '../functions/recovery'
13// request: ask for a link · sent: asked · checking: reading a link
14// reset: choose a password · invalid: dead link · done: password changed
15const mode = state('request')
16const address = state('')
17const account = state('')
18const token = state('')
19const password = state('')
20const confirmation = state('')
21const busy = state(false)
22const error = state('')
23
24async function post(path, payload) {
25 const response = await fetch(path, {
26 method: 'POST', credentials: 'same-origin', cache: 'no-store',
27 headers: { 'Content-Type': 'application/json' },
28 body: JSON.stringify(payload),
29 })
30 const result = await response.json().catch(() => ({}))
31 return { ok: response.ok, status: response.status, result }
32}
33
34async function requestLink() {
35 if (busy()) return
36 error.set('')
37 if (!address().trim()) { error.set('Enter your mailbox address.'); return }
38 busy.set(true)
39 try {
40 const { ok, status } = await post('/webmail/auth/forgot', { address: address().trim() })
41 if (ok) { mode.set('sent'); return }
42 error.set(status === 429 ? 'Too many requests. Please wait a few minutes.' : status === 400 ? 'Enter your mailbox address.' : 'Something went wrong. Please try again.')
43 }
44 catch { error.set('Could not reach the server. Please try again.') }
45 finally { busy.set(false) }
46}
47
48async function checkLink() {
49 const found = tokenFromHash(useRoute().hash)
50 if (!found) return
51 token.set(found)
52 mode.set('checking')
53 try {
54 const { ok, status, result } = await post('/webmail/auth/reset/verify', { token: found })
55 if (ok) { account.set(result.account || ''); mode.set('reset'); return }
56 error.set(resetErrorMessage(status))
57 mode.set('invalid')
58 }
59 catch { error.set('Could not reach the server. Reload this page to try again.'); mode.set('invalid') }
60}
61
62async function choosePassword() {
63 if (busy()) return
64 error.set('')
65 const problem = newPasswordProblem(password(), confirmation())
66 if (problem) { error.set(problem); return }
67 busy.set(true)
68 try {
69 const { ok, status, result } = await post('/webmail/auth/reset', { token: token(), password: password() })
70 if (ok) { password.set(''); confirmation.set(''); account.set(result.account || account()); mode.set('done'); return }
71 error.set(resetErrorMessage(status, result.message || 'The password could not be changed. Please try again.'))
72 if (status === 400 && result.code === 'invalid_token') mode.set('invalid')
73 }
74 catch { error.set('Could not reach the server. Please try again.') }
75 finally { busy.set(false) }
76}
77
78function startOver() { error.set(''); token.set(''); mode.set('request') }
79
80onMount(checkLink)
81</script>
82<main class="login">
83 <div class="mb-4 font-semibold text-indigo-600 text-sm tracking-wide">MAIL</div>
84
85 <section :if="mode === 'request'">
86 <h1>Forgot your password?</h1>
87 <p class="login-sub">Enter your mailbox address. If it has a recovery address, we will email a reset link there.</p>
88 <form @submit.prevent="requestLink()">
89 <label for="address">Mailbox address</label>
90 <input id="address" type="text" x-model.trim="address" autocomplete="username" placeholder="you@example.com" required autofocus>
91 <p :if="error" :text="error" class="login-error" role="alert"></p>
92 <button type="submit" :disabled="busy" :text="busy ? 'Sending…' : 'Email me a reset link'"></button>
93 </form>
94 </section>
95
96 <section :if="mode === 'sent'">
97 <h1>Check your recovery inbox</h1>
98 <p class="login-sub" role="status">If <strong :text="address"></strong> has a recovery address, a reset link is on its way there. It works once and expires in 30 minutes.</p>
99 <p class="login-sub">Nothing after a few minutes? Check spam, or ask your mail administrator: the mailbox may have no recovery address.</p>
100 </section>
101
102 <section :if="mode === 'checking'">
103 <h1>Reset your password</h1>
104 <p class="login-sub" role="status">Checking your reset link…</p>
105 </section>
106
107 <section :if="mode === 'reset'">
108 <h1>Choose a new password</h1>
109 <p class="login-sub">For <strong :text="account"></strong>. It replaces the password in webmail and in your mail apps, and signs out every session.</p>
110 <form @submit.prevent="choosePassword()">
111 <input type="text" :value="account" autocomplete="username" hidden readonly>
112 <label for="newPassword">New password</label>
113 <input id="newPassword" type="password" x-model="password" autocomplete="new-password" minlength="12" required autofocus>
114 <label for="confirmPassword">Confirm password</label>
115 <input id="confirmPassword" type="password" x-model="confirmation" autocomplete="new-password" minlength="12" required>
116 <p :if="error" :text="error" class="login-error" role="alert"></p>
117 <button type="submit" :disabled="busy" :text="busy ? 'Saving…' : 'Set new password'"></button>
118 </form>
119 </section>
120
121 <section :if="mode === 'invalid'">
122 <h1>This link cannot be used</h1>
123 <p :text="error" class="login-error" role="alert"></p>
124 <button type="button" @click="startOver()">Ask for a new link</button>
125 </section>
126
127 <section :if="mode === 'done'">
128 <h1>Password changed</h1>
129 <p class="login-sub" role="status">Sign in to <strong :text="account"></strong> with your new password, and update it in your mail apps.</p>
130 <a class="login-link" href="/login">Sign in</a>
131 </section>
132
133 <p :if="mode !== 'done'" class="login-sub"><a class="login-link" href="/login">Back to sign in</a></p>
134 <noscript>JavaScript is required to reset your password.</noscript>
135</main>
136</body>
137</html>
packages/webmail/public/styles.cssmodified+6-0
Changes to packages/webmail/public/styles.css
@@ -56,6 +56,12 @@ body {
5656 cursor: pointer;
5757}
5858.login button:disabled { opacity: 0.6; cursor: default; }
59.login-link { color: var(--accent); font-weight: 600; text-decoration: none; }
60.login-link:hover, .login-link:focus-visible { text-decoration: underline; }
61.login-forgot { margin: 14px 0 0; text-align: center; font-size: 13px; }
62.login .login-secondary { background: transparent; color: var(--accent); border: 1px solid var(--border); margin-top: 10px; }
63.login-recovery { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
64.login-recovery h2 { margin: 0 0 4px; font-size: 16px; }
5965.login-error {
6066 margin: 12px 0 0;
6167 color: #c0392b;
packages/webmail/recovery.test.tsadded+41-0
Changes to packages/webmail/recovery.test.ts
@@ -0,0 +1,41 @@
1import { describe, expect, test } from 'bun:test'
2import { newPasswordProblem, recoveryAddressProblem, resetErrorMessage, tokenFromHash } from './functions/recovery'
3
4const token = 'ab'.repeat(32)
5
6describe('reset links', () => {
7 test('read the token from the fragment and nothing else', () => {
8 expect(tokenFromHash(`#token=${token}`)).toBe(token)
9 expect(tokenFromHash(`token=${token.toUpperCase()}`)).toBe(token)
10 expect(tokenFromHash('')).toBe('')
11 expect(tokenFromHash('#token=short')).toBe('')
12 expect(tokenFromHash(`#token=${token}zz`)).toBe('')
13 expect(tokenFromHash(`#other=${token}`)).toBe('')
14 })
15
16 test('explain failures without telling anything about the mailbox', () => {
17 expect(resetErrorMessage(400)).toContain('invalid, already used or expired')
18 expect(resetErrorMessage(429)).toContain('wait')
19 expect(resetErrorMessage(500, 'fallback')).toBe('fallback')
20 })
21})
22
23describe('new password', () => {
24 test('matches the server bounds and asks for a matching confirmation', () => {
25 expect(newPasswordProblem('short', 'short')).toContain('12')
26 expect(newPasswordProblem('long-enough-pass', 'different-pass!')).toContain('do not match')
27 expect(newPasswordProblem('x'.repeat(1025), 'x'.repeat(1025))).toContain('1024')
28 expect(newPasswordProblem('long-enough-pass', 'long-enough-pass')).toBe('')
29 })
30})
31
32describe('recovery address', () => {
33 test('is an address outside the mailbox, or empty to remove it', () => {
34 expect(recoveryAddressProblem('pawel@hq.training', 'pawel@outlook.de')).toBe('')
35 expect(recoveryAddressProblem('pawel@hq.training', ' ')).toBe('')
36 expect(recoveryAddressProblem('pawel@hq.training', 'Pawel@HQ.training')).toContain('outside this mailbox')
37 expect(recoveryAddressProblem('pawel@hq.training', 'not-an-address')).toContain('valid')
38 expect(recoveryAddressProblem('pawel@hq.training', 'x@localhost')).toContain('valid')
39 expect(recoveryAddressProblem('pawel@hq.training', 'a b@c.de')).toContain('valid')
40 })
41})
packages/webmail/server.tsmodified+1-1
Changes to packages/webmail/server.ts
@@ -21,7 +21,7 @@ async function main(): Promise<void> {
2121 }
2222 catch { return Response.json({ message: 'Mail API is unavailable.' }, { status: 502 }) }
2323 }
24 const assets: Record<string, string> = { '/': 'index.html', '/login': 'login.html', '/styles.css': 'styles.css' }
24 const assets: Record<string, string> = { '/': 'index.html', '/login': 'login.html', '/forgot': 'recover.html', '/reset': 'recover.html', '/styles.css': 'styles.css' }
2525 const file = assets[url.pathname]
2626 return file ? new Response(Bun.file(`${dist}/${file}`)) : new Response('Not found', { status: 404 })
2727 },
packages/zig/src/api/webmail_compose.zigmodified+16-0
Changes to packages/zig/src/api/webmail_compose.zig
@@ -66,6 +66,9 @@ pub const Message = struct {
6666 attachments: []const Attachment = &.{},
6767 /// Only drafts and the sender's private Sent copy retain Bcc headers.
6868 include_bcc: bool = false,
69 /// Mark a message the server wrote itself (RFC 3834), so vacation
70 /// responders and auto-forwarders do not answer it.
71 auto_submitted: bool = false,
6972};
7073
7174pub const SendConfig = struct {
@@ -273,6 +276,7 @@ pub fn buildMime(allocator: std.mem.Allocator, msg: Message, ts: i64, msgid: []c
273276 try w.print("Message-ID: {s}\r\n", .{msgid});
274277 if (msg.in_reply_to.len > 0) try w.print("In-Reply-To: {s}\r\n", .{msg.in_reply_to});
275278 if (msg.references.len > 0) try w.print("References: {s}\r\n", .{msg.references});
279 if (msg.auto_submitted) try w.writeAll("Auto-Submitted: auto-generated\r\n");
276280 try w.writeAll("MIME-Version: 1.0\r\n");
277281
278282 const has_attachments = msg.attachments.len > 0;
@@ -501,6 +505,18 @@ pub fn send(allocator: std.mem.Allocator, msg: Message, cfg: SendConfig) !SendRe
501505 return .{ .message_id = msgid, .delivered = delivered, .failed = try failed.toOwnedSlice(allocator), .sent_saved = sent_saved };
502506}
503507
508/// Deliver a message the server writes on its own behalf (password reset
509/// links and account notices): the same local-or-remote path and DKIM signing
510/// as a composed message, with no Sent copy, because no mailbox sent it.
511pub fn deliverSystem(allocator: std.mem.Allocator, msg: Message, cfg: SendConfig) !void {
512 if (msg.to.len == 0) return ComposeError.NoRecipients;
513 const ts = time_compat.timestamp();
514 const seq = nameSeq.fetchAdd(1, .monotonic);
515 const msgid = try std.fmt.allocPrint(allocator, "<{d}.{d}@{s}>", .{ ts, seq, cfg.hostname });
516 const raw = try buildMime(allocator, msg, ts, msgid);
517 for (msg.to) |recipient| try deliverOne(allocator, cfg, msg.from, recipient, raw);
518}
519
504520// ── Tests ────────────────────────────────────────────────────────────────────
505521
506522test "isSafeAddress rejects injection, path traversal, and malformed" {
packages/zig/src/api/webmail_http.zigmodified+296-0
Changes to packages/zig/src/api/webmail_http.zig
@@ -8,6 +8,12 @@
88//! POST /webmail/auth/login {username,password} -> sets session cookie
99//! POST /webmail/auth/logout -> clears cookie
1010//! GET /webmail/auth/me -> {user}
11//! POST /webmail/auth/forgot {address} -> always {ok:true}; mails a reset
12//! link to the mailbox's recovery address
13//! POST /webmail/auth/reset/verify {token} -> {account} | 400 invalid_token
14//! POST /webmail/auth/reset {token,password} -> sets the new password
15//! GET /webmail/auth/recovery -> {recovery,setBy}
16//! PUT /webmail/auth/recovery {currentPassword,recovery} -> changes it
1117//! GET /webmail/api/folders -> [{name,total,unread}]
1218//! GET /webmail/api/messages?folder=&page=&per_page= -> {items,total,page}
1319//! GET /webmail/api/messages/:uid?folder= -> {message}
@@ -41,12 +47,14 @@ const store = ;
4147const threads = ;
4248const ascii_compat = ;
4349const caldav_store = ;
50const recovery = ;
4451
4552// SPA static assets, rendered by packages/webmail/build.ts into webmail_dist/
4653// (gitignored, regenerated by `zig build`) and baked into the binary so the whole
4754// webmail UI ships as one executable — same single-binary model as admin.html.
4855const index_html = @embedFile("webmail_dist/index.html");
4956const login_html = @embedFile("webmail_dist/login.html");
57const recover_html = @embedFile("webmail_dist/recover.html");
5058const styles_css = @embedFile("webmail_dist/styles.css");
5159
5260pub const WebmailHttpConfig = struct {
@@ -86,6 +94,10 @@ pub const WebmailHttpServer = struct {
8694 /// Throttles login attempts per IP to blunt credential brute-forcing.
8795 /// Its own internal mutex makes it safe to share across connection threads.
8896 login_limiter: security.RateLimiter,
97 /// Throttles the unauthenticated password reset endpoints per IP: asking
98 /// for links (by IP) and presenting tokens (by "token:<ip>"). The per
99 /// mailbox cap on links sent lives in the database (webmail_recovery.zig).
100 reset_limiter: security.RateLimiter,
89101 /// Loaded once at init when TLS is enabled (shared read-only across threads).
90102 cert_key_pair: ?tls.config.CertKeyPair = null,
91103 listener: ?socket.Server = null,
@@ -125,6 +137,7 @@ pub const WebmailHttpServer = struct {
125137 config.login_rate_max,
126138 config.login_rate_window_seconds,
127139 ),
140 .reset_limiter = security.RateLimiter.init(allocator, 15 * 60, 10, 30, 15 * 60),
128141 .running = std.atomic.Value(bool).init(false),
129142 };
130143 if (config.enable_tls and config.tls_cert_path != null and config.tls_key_path != null) {
@@ -145,6 +158,7 @@ pub const WebmailHttpServer = struct {
145158 pub fn deinit(self: *WebmailHttpServer) void {
146159 self.stop();
147160 self.login_limiter.deinit();
161 self.reset_limiter.deinit();
148162 }
149163
150164 pub fn start(self: *WebmailHttpServer) !void {
@@ -455,6 +469,21 @@ fn handleConnection(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena
455469 if (std.mem.eql(u8, p, "/webmail/auth/me") and std.mem.eql(u8, req.method, "GET")) {
456470 return handleMe(server, conn, arena, req);
457471 }
472 if (std.mem.eql(u8, p, "/webmail/auth/forgot") and std.mem.eql(u8, req.method, "POST")) {
473 return handleForgot(server, conn, arena, req);
474 }
475 if (std.mem.eql(u8, p, "/webmail/auth/reset/verify") and std.mem.eql(u8, req.method, "POST")) {
476 return handleResetVerify(server, conn, arena, req);
477 }
478 if (std.mem.eql(u8, p, "/webmail/auth/reset") and std.mem.eql(u8, req.method, "POST")) {
479 return handleResetComplete(server, conn, arena, req);
480 }
481 if (std.mem.eql(u8, p, "/webmail/auth/recovery") and std.mem.eql(u8, req.method, "GET")) {
482 return handleRecoveryGet(server, conn, arena, req);
483 }
484 if (std.mem.eql(u8, p, "/webmail/auth/recovery") and std.mem.eql(u8, req.method, "PUT")) {
485 return handleRecoveryUpdate(server, conn, arena, req);
486 }
458487 if (std.mem.eql(u8, p, "/webmail/api/compose") and std.mem.eql(u8, req.method, "POST")) {
459488 return handleCompose(server, conn, arena, req);
460489 }
@@ -495,6 +524,10 @@ fn handleConnection(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena
495524 if (std.mem.eql(u8, p, "/login")) {
496525 return serveAsset(conn, arena, login_html, "text/html; charset=utf-8");
497526 }
527 // One page asks for a link (/forgot) and spends it (/reset#token=...).
528 if (std.mem.eql(u8, p, "/forgot") or std.mem.eql(u8, p, "/reset")) {
529 return serveAsset(conn, arena, recover_html, "text/html; charset=utf-8");
530 }
498531 if (std.mem.eql(u8, p, "/styles.css")) {
499532 return serveAsset(conn, arena, styles_css, "text/css; charset=utf-8");
500533 }
@@ -515,6 +548,9 @@ fn serveAsset(conn: *webmail_tls.Stream, arena: std.mem.Allocator, body: []const
515548 try w.writeAll("X-Frame-Options: DENY\r\n");
516549 if (std.mem.indexOf(u8, content_type, "text/html") != null) {
517550 try w.writeAll("Cache-Control: no-cache\r\n");
551 // A reset page carries its token in the URL fragment; no link on any
552 // page should hand the URL to another site.
553 try w.writeAll("Referrer-Policy: no-referrer\r\n");
518554 } else {
519555 try w.writeAll("Cache-Control: public, max-age=3600\r\n");
520556 }
@@ -681,6 +717,266 @@ fn handleMe(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.me
681717 try sendJson(conn, arena, 200, body.items, null);
682718}
683719
720// ── Password reset and recovery address ─────────────────────────────────────
721
722fn clientIp(req: Request, conn: *webmail_tls.Stream) []const u8 {
723 return if (req.trust_forwarded_headers) req.header("X-Real-IP") orelse conn.peerIp() else conn.peerIp();
724}
725
726/// The authority the browser used, as isSameOrigin reads it.
727fn requestHost(req: Request) ?[]const u8 {
728 return if (req.trust_forwarded_headers) req.header("X-Forwarded-Host") orelse req.header("Host") else req.header("Host");
729}
730
731/// Links point at https whenever the browser is on https: direct TLS here, or
732/// a TLS gateway in front of a listener that sets Secure cookies.
733fn publicHttps(server: *const WebmailHttpServer) bool {
734 return server.config.enable_tls or server.config.secure_cookies;
735}
736
737const SystemMail = struct {
738 server: *WebmailHttpServer,
739 arena: std.heap.ArenaAllocator,
740 message: compose.Message,
741 what: []const u8,
742 about: []const u8,
743};
744
745/// Deliver a server-written message off the request thread, so a reset
746/// request is answered in the same time whether or not anything is sent, and
747/// a slow remote MX cannot hold a connection open. Counted as an active
748/// connection so shutdown waits for it. Takes ownership of `job_arena`, which
749/// must hold every byte `message` points at.
750fn dispatchSystemMail(server: *WebmailHttpServer, job_arena: std.heap.ArenaAllocator, message: compose.Message, what: []const u8, about: []const u8) void {
751 var owned = job_arena;
752 const job = std.heap.page_allocator.create(SystemMail) catch {
753 owned.deinit();
754 logger.err("Webmail {s} for {s} not sent: out of memory", .{ what, about });
755 return;
756 };
757 job.* = .{ .server = server, .arena = owned, .message = message, .what = what, .about = about };
758 _ = server.active_connections.fetchAdd(1, .monotonic);
759 const thread = std.Thread.spawn(.{}, systemMailThread, .{job}) catch |err| {
760 _ = server.active_connections.fetchSub(1, .release);
761 logger.err("Webmail {s} for {s} not sent: {}", .{ what, about, err });
762 job.arena.deinit();
763 std.heap.page_allocator.destroy(job);
764 return;
765 };
766 thread.detach();
767}
768
769fn systemMailThread(job: *SystemMail) void {
770 const server = job.server;
771 defer {
772 job.arena.deinit();
773 std.heap.page_allocator.destroy(job);
774 _ = server.active_connections.fetchSub(1, .release);
775 }
776 const cfg = compose.SendConfig{
777 .hostname = server.config.hostname,
778 .delivery_method = server.config.delivery_method,
779 .ses_region = server.config.ses_region,
780 .sender_user = "",
781 .mail_config = server.config.mail_config,
782 .auth = server.auth,
783 };
784 compose.deliverSystem(job.arena.allocator(), job.message, cfg) catch |err| {
785 logger.warn("Webmail {s} for {s} could not be delivered: {}", .{ job.what, job.about, err });
786 return;
787 };
788 logger.info("Webmail {s} for {s} delivered to its recovery address", .{ job.what, job.about });
789}
790
791const reset_accepted = "{\"ok\":true}";
792
793/// A server-written message plus the mailbox it is about, both in a job arena.
794const Built = struct { message: compose.Message, about: []const u8 };
795
796/// POST /webmail/auth/forgot {address}
797///
798/// Always answers {ok:true}, whether or not the mailbox exists, has a recovery
799/// address, or was sent anything: the answer must not tell anyone which
800/// mailboxes exist. The link goes to the recovery address, never to the
801/// requester, and is sent from another thread so timing tells nothing either.
802fn handleForgot(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
803 if (!isSameOrigin(req)) return sendError(conn, arena, 403, "forbidden", "Cross-origin request rejected");
804 const ip = clientIp(req, conn);
805 if (ip.len > 0 and !(server.reset_limiter.checkAndIncrement(ip) catch true)) {
806 logger.warn("Webmail password reset requests rate-limited for {s}", .{ip});
807 return sendError(conn, arena, 429, "rate_limited", "Too many requests. Please wait a few minutes.");
808 }
809 const raw = (try jsonStringFieldAlloc(arena, req.body, "address")) orelse return sendError(conn, arena, 400, "bad_request", "Enter your mailbox address");
810 const address = std.mem.trim(u8, raw, " \t\r\n");
811 if (address.len == 0 or address.len > 320) return sendError(conn, arena, 400, "bad_request", "Enter your mailbox address");
812 for (address) |c| if (c <= 0x20 or c == 0x7f) return sendError(conn, arena, 400, "bad_request", "Enter your mailbox address");
813
814 const username = try server.auth.canonicalUsername(address, arena);
815 const result = recovery.issue(arena, server.db, username, if (ip.len > 0) ip else null, time_compat.timestamp()) catch |err| {
816 logger.err("Webmail password reset for {s} failed: {}", .{ username, err });
817 return sendJson(conn, arena, 200, reset_accepted, null);
818 };
819 switch (result) {
820 .issued => |issued| {
821 var job = std.heap.ArenaAllocator.init(std.heap.page_allocator);
822 const ja = job.allocator();
823 const message: ?Built = blk: {
824 const about = ja.dupe(u8, username) catch break :blk null;
825 const base = recovery.linkBase(ja, requestHost(req), about, server.config.hostname, publicHttps(server)) catch break :blk null;
826 const link = recovery.resetLink(ja, base, &issued.token) catch break :blk null;
827 const built = recovery.resetMessage(ja, .{
828 .username = about,
829 .recovery = ja.dupe(u8, issued.recovery) catch break :blk null,
830 .link = link,
831 .ip = if (ip.len > 0) (ja.dupe(u8, ip) catch null) else null,
832 .hostname = server.config.hostname,
833 }) catch break :blk null;
834 break :blk .{ .message = built, .about = about };
835 };
836 if (message) |m| {
837 logger.info("Webmail password reset link requested for {s} from {s}", .{ username, ip });
838 dispatchSystemMail(server, job, m.message, "password reset link", m.about);
839 } else {
840 job.deinit();
841 logger.err("Webmail password reset link for {s} could not be built", .{username});
842 }
843 },
844 else => logger.info("Webmail password reset requested for {s} from {s}: nothing sent ({s})", .{ username, ip, @tagName(result) }),
845 }
846 return sendJson(conn, arena, 200, reset_accepted, null);
847}
848
849fn resetTokenAllowed(server: *WebmailHttpServer, arena: std.mem.Allocator, req: Request, conn: *webmail_tls.Stream) !bool {
850 const ip = clientIp(req, conn);
851 if (ip.len == 0) return true;
852 const key = try std.fmt.allocPrint(arena, "token:{s}", .{ip});
853 return server.reset_limiter.checkAndIncrementUser(key) catch true;
854}
855
856const invalid_token_message = "This reset link is invalid, already used or expired. Ask for a new one.";
857
858/// POST /webmail/auth/reset/verify {token} -> {ok, account}
859/// Lets the reset page say which mailbox it is for before asking for a
860/// password. Does not spend the token.
861fn handleResetVerify(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
862 if (!isSameOrigin(req)) return sendError(conn, arena, 403, "forbidden", "Cross-origin request rejected");
863 if (!try resetTokenAllowed(server, arena, req, conn)) return sendError(conn, arena, 429, "rate_limited", "Too many attempts. Please wait a few minutes.");
864 const token = (try jsonStringFieldAlloc(arena, req.body, "token")) orelse return sendError(conn, arena, 400, "invalid_token", invalid_token_message);
865 const account = (recovery.lookup(arena, server.db, token, time_compat.timestamp()) catch {
866 return sendError(conn, arena, 500, "server_error", "Could not check this link");
867 }) orelse return sendError(conn, arena, 400, "invalid_token", invalid_token_message);
868 var body: std.ArrayList(u8) = .empty;
869 const w = jw(&body, arena);
870 try w.writeAll("{\"ok\":true,\"account\":");
871 try writeJsonString(w, account);
872 try w.writeAll("}");
873 try sendJson(conn, arena, 200, body.items, null);
874}
875
876/// POST /webmail/auth/reset {token, password}
877/// Spends the token, sets the password (webmail, IMAP and SMTP share it),
878/// signs out every session of that mailbox, and tells the recovery address.
879fn handleResetComplete(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
880 if (!isSameOrigin(req)) return sendError(conn, arena, 403, "forbidden", "Cross-origin request rejected");
881 if (!try resetTokenAllowed(server, arena, req, conn)) return sendError(conn, arena, 429, "rate_limited", "Too many attempts. Please wait a few minutes.");
882 const token = (try jsonStringFieldAlloc(arena, req.body, "token")) orelse return sendError(conn, arena, 400, "invalid_token", invalid_token_message);
883 const password = (try jsonStringFieldAlloc(arena, req.body, "password")) orelse return sendError(conn, arena, 400, "bad_request", "Missing new password");
884 if (password.len < recovery.min_password_len or password.len > recovery.max_password_len)
885 return sendError(conn, arena, 400, "bad_request", "Use a password between 12 and 1024 characters");
886 const done = (recovery.complete(arena, server.db, server.auth, token, password, time_compat.timestamp()) catch |err| {
887 logger.err("Webmail password reset could not be completed: {}", .{err});
888 return sendError(conn, arena, 500, "server_error", "The password could not be changed. Please try again.");
889 }) orelse return sendError(conn, arena, 400, "invalid_token", invalid_token_message);
890
891 logger.info("Webmail password reset completed for {s} from {s}", .{ done.username, clientIp(req, conn) });
892
893 var job = std.heap.ArenaAllocator.init(std.heap.page_allocator);
894 const ja = job.allocator();
895 const notice: ?Built = blk: {
896 const about = ja.dupe(u8, done.username) catch break :blk null;
897 const to = ja.dupe(u8, done.recovery) catch break :blk null;
898 const built = recovery.passwordResetNotice(ja, about, to, server.config.hostname) catch break :blk null;
899 break :blk .{ .message = built, .about = about };
900 };
901 if (notice) |n| dispatchSystemMail(server, job, n.message, "password reset notice", n.about) else job.deinit();
902
903 var body: std.ArrayList(u8) = .empty;
904 const w = jw(&body, arena);
905 try w.writeAll("{\"ok\":true,\"account\":");
906 try writeJsonString(w, done.username);
907 try w.writeAll("}");
908 try sendJson(conn, arena, 200, body.items, try server.sessions.buildClearCookie());
909}
910
911fn writeRecovery(w: anytype, current: ?recovery.Recovery) !void {
912 try w.writeAll("{\"recovery\":");
913 if (current) |r| {
914 if (r.address) |address| try writeJsonString(w, address) else try w.writeAll("null");
915 try w.writeAll(",\"setBy\":");
916 if (r.address != null and r.source != null) try writeJsonString(w, @tagName(r.source.?)) else try w.writeAll("null");
917 } else try w.writeAll("null,\"setBy\":null");
918 try w.writeAll("}");
919}
920
921/// GET /webmail/auth/recovery -> {recovery, setBy}
922fn handleRecoveryGet(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
923 var sess = (try requireSession(server, conn, arena, req)) orelse return;
924 defer sess.deinit(server.allocator);
925 const current = recovery.get(arena, server.db, sess.username) catch return sendError(conn, arena, 500, "server_error", "Could not read the recovery address");
926 var body: std.ArrayList(u8) = .empty;
927 try writeRecovery(jw(&body, arena), current);
928 try sendJson(conn, arena, 200, body.items, null);
929}
930
931/// PUT /webmail/auth/recovery {currentPassword, recovery}
932/// Re-authenticates with the mailbox password: a borrowed session must not be
933/// enough to redirect the account's reset links. An empty `recovery` removes
934/// it. The previous address, if any, is told about the change.
935fn handleRecoveryUpdate(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
936 if (!isSameOrigin(req)) return sendError(conn, arena, 403, "forbidden", "Cross-origin request rejected");
937 var sess = (try requireSession(server, conn, arena, req)) orelse return;
938 defer sess.deinit(server.allocator);
939 const current_password = (try jsonStringFieldAlloc(arena, req.body, "currentPassword")) orelse return sendError(conn, arena, 400, "bad_request", "Enter your current password");
940 const wanted_raw = (try jsonStringFieldAlloc(arena, req.body, "recovery")) orelse return sendError(conn, arena, 400, "bad_request", "Missing recovery address");
941 const key = try std.fmt.allocPrint(arena, "recovery:{s}", .{sess.username});
942 if (!try server.login_limiter.checkAndIncrementUser(key)) return sendError(conn, arena, 429, "rate_limited", "Too many attempts. Please wait five minutes");
943 if (!try server.auth.verifyCredentials(sess.username, current_password)) return sendError(conn, arena, 400, "invalid_credentials", "Current password is incorrect");
944
945 const trimmed = std.mem.trim(u8, wanted_raw, " \t\r\n");
946 const wanted: ?[]const u8 = if (trimmed.len == 0) null else recovery.validateAddress(sess.username, trimmed) catch |err| return switch (err) {
947 recovery.RecoveryError.SameAsMailbox => sendError(conn, arena, 400, "bad_request", "Use an address outside this mailbox: a reset link sent here could not be read without the password"),
948 else => sendError(conn, arena, 400, "bad_request", "Enter a valid email address"),
949 };
950
951 const previous = recovery.get(arena, server.db, sess.username) catch return sendError(conn, arena, 500, "server_error", "Could not read the recovery address");
952 recovery.set(server.db, sess.username, wanted, .user, time_compat.timestamp()) catch |err| {
953 logger.err("Webmail recovery address update for {s} failed: {}", .{ sess.username, err });
954 return sendError(conn, arena, 500, "server_error", "Could not save the recovery address");
955 };
956 logger.info("Webmail recovery address {s} for {s} from {s}", .{ if (wanted == null) "removed" else "changed", sess.username, clientIp(req, conn) });
957
958 const old: ?[]const u8 = if (previous) |p| p.address else null;
959 if (old) |old_address| {
960 const same = if (wanted) |w| std.ascii.eqlIgnoreCase(w, old_address) else false;
961 if (!same) {
962 var job = std.heap.ArenaAllocator.init(std.heap.page_allocator);
963 const ja = job.allocator();
964 const notice: ?Built = blk: {
965 const about = ja.dupe(u8, sess.username) catch break :blk null;
966 const to = ja.dupe(u8, old_address) catch break :blk null;
967 const built = recovery.recoveryChangedNotice(ja, about, to, server.config.hostname) catch break :blk null;
968 break :blk .{ .message = built, .about = about };
969 };
970 if (notice) |n| dispatchSystemMail(server, job, n.message, "recovery address change notice", n.about) else job.deinit();
971 }
972 }
973
974 const now_value = recovery.get(arena, server.db, sess.username) catch null;
975 var body: std.ArrayList(u8) = .empty;
976 try writeRecovery(jw(&body, arena), now_value);
977 try sendJson(conn, arena, 200, body.items, null);
978}
979
684980const ComposePayload = service_mod.Payload;
685981
686982fn handleDraftSave(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void {
packages/zig/src/api/webmail_recovery.zigadded+676-0
Changes to packages/zig/src/api/webmail_recovery.zig
@@ -0,0 +1,676 @@
1//! Mailbox recovery: a per-mailbox recovery address and the password reset
2//! links sent to it.
3//!
4//! A mailbox may name one address outside itself where a reset link can be
5//! sent. Anyone can ask for a reset by mailbox address; the answer is the same
6//! whether or not the mailbox exists or has a recovery address, and the link
7//! goes only to the recovery address, never to whoever asked.
8//!
9//! Who set the address is recorded in `users.recovery_source`:
10//! - `operator`: the `user:local recovery` CLI, which deploy provisioning uses
11//! - `user`: the mailbox owner, in webmail settings
12//! Provisioning with the `initial` policy leaves a `user` value alone, so a
13//! redeploy does not undo a change the owner made. `managed` always applies.
14//!
15//! Reset tokens are 32 random bytes, sent as 64 hex characters. Only their
16//! SHA-256 is stored (`mailbox_reset_tokens`). A token is single use, expires
17//! after `token_ttl_seconds`, and is voided when a newer one is issued, when
18//! the password changes by any route, and when the recovery address changes.
19
20const std = ;
21const database = ;
22const auth_mod = ;
23const io_compat = ;
24const compose = ;
25
26/// How long a reset link stays usable.
27pub const token_ttl_seconds: i64 = 30 * 60;
28/// At most this many reset emails per mailbox per hour, whoever asks.
29pub const max_links_per_hour: i64 = 3;
30/// And never two within this many seconds of each other.
31pub const min_link_interval_seconds: i64 = 60;
32/// Token length on the wire: 32 bytes as lowercase hex.
33pub const token_hex_len = 64;
34/// The bounds the webmail password change already enforces.
35pub const min_password_len = 12;
36pub const max_password_len = 1024;
37
38pub const RecoveryError = error{
39 InvalidAddress,
40 SameAsMailbox,
41 UserNotFound,
42};
43
44pub const Source = enum {
45 operator,
46 user,
47
48 pub fn parse(text: []const u8) ?Source {
49 if (std.mem.eql(u8, text, "operator")) return .operator;
50 if (std.mem.eql(u8, text, "user")) return .user;
51 return null;
52 }
53};
54
55pub const Policy = enum {
56 /// Set the address unless the mailbox owner has set their own.
57 initial,
58 /// Always set the address.
59 managed,
60
61 pub fn parse(text: []const u8) ?Policy {
62 if (std.mem.eql(u8, text, "initial")) return .initial;
63 if (std.mem.eql(u8, text, "managed")) return .managed;
64 return null;
65 }
66};
67
68pub const Recovery = struct {
69 /// null when the mailbox has none.
70 address: ?[]const u8,
71 source: ?Source,
72 updated_at: ?i64,
73};
74
75// ── Recovery address ─────────────────────────────────────────────────────────
76
77/// Trim and check a recovery address for `mailbox`. Returns the trimmed slice
78/// (borrowed from `address`). The mailbox itself is refused: a reset link sent
79/// to the mailbox you cannot open is no recovery at all.
80pub fn validateAddress(mailbox: []const u8, address: []const u8) RecoveryError![]const u8 {
81 const trimmed = std.mem.trim(u8, address, " \t\r\n");
82 if (trimmed.len == 0 or trimmed.len > 254) return RecoveryError.InvalidAddress;
83 if (!compose.isSafeAddress(trimmed)) return RecoveryError.InvalidAddress;
84 const at = std.mem.indexOfScalar(u8, trimmed, '@').?;
85 // A domain needs a dot; `user@localhost` is not somewhere a person reads mail.
86 if (std.mem.indexOfScalar(u8, trimmed[at + 1 ..], '.') == null) return RecoveryError.InvalidAddress;
87 if (std.ascii.eqlIgnoreCase(trimmed, mailbox)) return RecoveryError.SameAsMailbox;
88 return trimmed;
89}
90
91/// The mailbox's recovery address, or null when the mailbox does not exist.
92pub fn get(allocator: std.mem.Allocator, db: *database.Database, username: []const u8) !?Recovery {
93 const s = try db.prepare("SELECT recovery_email, recovery_source, recovery_updated_at FROM users WHERE username = ?1");
94 defer s.finalize();
95 try s.bind(1, username);
96 if (!try s.step()) return null;
97 const address = s.columnTextOpt(0);
98 const source = s.columnTextOpt(1);
99 return Recovery{
100 .address = if (address) |a| (if (a.len == 0) null else try allocator.dupe(u8, a)) else null,
101 .source = if (source) |text| Source.parse(text) else null,
102 .updated_at = s.columnInt64Opt(2),
103 };
104}
105
106/// Set (or, with null, clear) a mailbox's recovery address. Outstanding reset
107/// links are voided: they were sent to the previous address.
108pub fn set(db: *database.Database, username: []const u8, address: ?[]const u8, source: Source, now: i64) !void {
109 const value: ?[]const u8 = if (address) |a| try validateAddress(username, a) else null;
110 const s = try db.prepare(
111 \\UPDATE users SET recovery_email = ?2, recovery_source = ?3, recovery_updated_at = ?4
112 \\WHERE username = ?1 RETURNING username
113 );
114 defer s.finalize();
115 try s.bind(1, username);
116 try s.bindOpt(2, value);
117 try s.bind(3, @tagName(source));
118 try s.bind(4, now);
119 if (!try s.step()) return RecoveryError.UserNotFound;
120 try db.deleteUserResetTokens(username);
121}
122
123pub const ApplyResult = enum {
124 /// The address was written.
125 updated,
126 /// It already had this address.
127 unchanged,
128 /// The owner set their own and the policy is `initial`, so it was kept.
129 kept_owner_choice,
130};
131
132/// What deploy provisioning calls: make the mailbox's recovery address
133/// `address`, subject to `policy`. Idempotent.
134pub fn apply(allocator: std.mem.Allocator, db: *database.Database, username: []const u8, address: []const u8, policy: Policy, now: i64) !ApplyResult {
135 const wanted = try validateAddress(username, address);
136 const current = (try get(allocator, db, username)) orelse return RecoveryError.UserNotFound;
137 defer if (current.address) |a| allocator.free(a);
138 if (current.address) |existing| {
139 if (std.ascii.eqlIgnoreCase(existing, wanted)) return .unchanged;
140 }
141 if (policy == .initial and current.source == .user) return .kept_owner_choice;
142 try set(db, username, wanted, .operator, now);
143 return .updated;
144}
145
146// ── Reset tokens ─────────────────────────────────────────────────────────────
147
148/// Hex SHA-256 of a token: what is stored and looked up.
149pub fn hashToken(token: []const u8) [64]u8 {
150 var digest: [32]u8 = undefined;
151 std.crypto.hash.sha2.Sha256.hash(token, &digest, .{});
152 return hexLower(32, digest);
153}
154
155fn hexLower(comptime n: usize, bytes: [n]u8) [n * 2]u8 {
156 const digits = "0123456789abcdef";
157 var out: [n * 2]u8 = undefined;
158 for (bytes, 0..) |byte, i| {
159 out[i * 2] = digits[byte >> 4];
160 out[i * 2 + 1] = digits[byte & 15];
161 }
162 return out;
163}
164
165/// A token as it arrives from a browser: exactly 64 lowercase hex characters.
166/// Anything else is rejected before it costs a database lookup.
167pub fn wellFormedToken(token: []const u8) bool {
168 if (token.len != token_hex_len) return false;
169 for (token) |c| if (!((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'))) return false;
170 return true;
171}
172
173pub const Issue = union(enum) {
174 /// A link should be sent to `recovery`.
175 issued: struct { token: [token_hex_len]u8, recovery: []const u8 },
176 /// Nothing is sent. These are distinguished for the server log only;
177 /// the HTTP answer is the same for all of them.
178 unknown_mailbox,
179 disabled,
180 no_recovery,
181 throttled,
182};
183
184/// Issue a reset token for `username` (a canonical mailbox key), unless the
185/// mailbox is unknown, disabled, has no recovery address, or has had too many
186/// links recently. A new token voids the mailbox's earlier unused ones.
187pub fn issue(allocator: std.mem.Allocator, db: *database.Database, username: []const u8, ip: ?[]const u8, now: i64) !Issue {
188 var recovery: []const u8 = undefined;
189 {
190 const s = try db.prepare("SELECT enabled, recovery_email FROM users WHERE username = ?1");
191 defer s.finalize();
192 try s.bind(1, username);
193 if (!try s.step()) return .unknown_mailbox;
194 if (s.columnInt64(0) == 0) return .disabled;
195 const address = s.columnTextOpt(1) orelse return .no_recovery;
196 if (address.len == 0) return .no_recovery;
197 recovery = try allocator.dupe(u8, address);
198 }
199 errdefer allocator.free(recovery);
200
201 // Old rows are only kept for the count below.
202 {
203 const s = try db.prepare("DELETE FROM mailbox_reset_tokens WHERE created_at < ?1");
204 defer s.finalize();
205 try s.bind(1, now - 24 * 60 * 60);
206 _ = try s.step();
207 }
208 {
209 const s = try db.prepare("SELECT COUNT(*), MAX(created_at) FROM mailbox_reset_tokens WHERE username = ?1 AND created_at > ?2");
210 defer s.finalize();
211 try s.bind(1, username);
212 try s.bind(2, now - 60 * 60);
213 if (try s.step()) {
214 const recent = s.columnInt64(0);
215 const last = s.columnInt64Opt(1);
216 const too_soon = if (last) |at| now - at < min_link_interval_seconds else false;
217 if (recent >= max_links_per_hour or too_soon) {
218 allocator.free(recovery);
219 return .throttled;
220 }
221 }
222 }
223 {
224 const s = try db.prepare("UPDATE mailbox_reset_tokens SET used_at = ?2 WHERE username = ?1 AND used_at IS NULL");
225 defer s.finalize();
226 try s.bind(1, username);
227 try s.bind(2, now);
228 _ = try s.step();
229 }
230
231 var random: [32]u8 = undefined;
232 io_compat.randomBytes(&random);
233 const token = hexLower(32, random);
234 const hash = hashToken(&token);
235 {
236 const s = try db.prepare(
237 \\INSERT INTO mailbox_reset_tokens (token_hash, username, recovery_email, created_at, expires_at, ip_address)
238 \\VALUES (?1, ?2, ?3, ?4, ?5, ?6)
239 );
240 defer s.finalize();
241 try s.bind(1, @as([]const u8, &hash));
242 try s.bind(2, username);
243 try s.bind(3, recovery);
244 try s.bind(4, now);
245 try s.bind(5, now + token_ttl_seconds);
246 try s.bindOpt(6, ip);
247 _ = try s.step();
248 }
249 return .{ .issued = .{ .token = token, .recovery = recovery } };
250}
251
252/// The mailbox a usable token belongs to, or null. Does not consume it.
253pub fn lookup(allocator: std.mem.Allocator, db: *database.Database, token: []const u8, now: i64) !?[]const u8 {
254 if (!wellFormedToken(token)) return null;
255 const hash = hashToken(token);
256 const s = try db.prepare(
257 \\SELECT t.username FROM mailbox_reset_tokens t JOIN users u ON u.username = t.username
258 \\WHERE t.token_hash = ?1 AND t.used_at IS NULL AND t.expires_at > ?2 AND u.enabled = 1
259 );
260 defer s.finalize();
261 try s.bind(1, @as([]const u8, &hash));
262 try s.bind(2, now);
263 if (!try s.step()) return null;
264 return try allocator.dupe(u8, s.columnText(0));
265}
266
267pub const Completed = struct {
268 username: []const u8,
269 /// Where the link was sent, for the confirmation notice.
270 recovery: []const u8,
271};
272
273/// Spend a token and set the mailbox's new password. Returns null when the
274/// token is unknown, used, expired, or its mailbox is gone or disabled.
275///
276/// The token is marked used by a single conditional UPDATE before anything
277/// else happens, so two concurrent submissions of one link cannot both win.
278pub fn complete(allocator: std.mem.Allocator, db: *database.Database, auth: *auth_mod.AuthBackend, token: []const u8, new_password: []const u8, now: i64) !?Completed {
279 if (!wellFormedToken(token)) return null;
280 const hash = hashToken(token);
281 var username: []const u8 = undefined;
282 var recovery: []const u8 = undefined;
283 {
284 const s = try db.prepare(
285 \\UPDATE mailbox_reset_tokens SET used_at = ?2
286 \\WHERE token_hash = ?1 AND used_at IS NULL AND expires_at > ?2
287 \\RETURNING username, recovery_email
288 );
289 defer s.finalize();
290 try s.bind(1, @as([]const u8, &hash));
291 try s.bind(2, now);
292 if (!try s.step()) return null;
293 username = try allocator.dupe(u8, s.columnText(0));
294 recovery = try allocator.dupe(u8, s.columnText(1));
295 }
296 errdefer {
297 allocator.free(username);
298 allocator.free(recovery);
299 }
300 {
301 const s = try db.prepare("SELECT enabled FROM users WHERE username = ?1");
302 defer s.finalize();
303 try s.bind(1, username);
304 if (!try s.step() or s.columnInt64(0) == 0) {
305 allocator.free(username);
306 allocator.free(recovery);
307 return null;
308 }
309 }
310 // Hashes the password, signs out every browser session, and voids the
311 // mailbox's other reset links.
312 try auth.changePassword(username, new_password);
313 return .{ .username = username, .recovery = recovery };
314}
315
316// ── Messages ─────────────────────────────────────────────────────────────────
317
318/// The domain a mailbox belongs to, falling back to the server's hostname for
319/// a legacy bare username.
320fn mailboxDomain(username: []const u8, hostname: []const u8) []const u8 {
321 if (std.mem.lastIndexOfScalar(u8, username, '@')) |at| return username[at + 1 ..];
322 return hostname;
323}
324
325/// The address the server writes from about `username`: `no-reply@` the
326/// mailbox's own domain, so it is DKIM-signed with that domain's key and
327/// aligns with its SPF and DMARC, exactly like mail the mailbox sends itself.
328pub fn senderFor(allocator: std.mem.Allocator, username: []const u8, hostname: []const u8) ![]const u8 {
329 return std.fmt.allocPrint(allocator, "no-reply@{s}", .{mailboxDomain(username, hostname)});
330}
331
332fn validHost(host: []const u8) bool {
333 if (host.len == 0 or host.len > 253) return false;
334 for (host) |c| {
335 if (!(std.ascii.isAlphanumeric(c) or c == '.' or c == '-' or c == ':')) return false;
336 }
337 return true;
338}
339
340/// Where a reset link points.
341///
342/// Never simply the request's Host: that is client-controlled, and a link
343/// built from it would carry the token to whatever server the requester named
344/// (reset poisoning). The request's host is used only when it is one of ours
345/// for this mailbox: `mail.<mailbox domain>` (the tenant's own webmail name)
346/// or the server's hostname. Otherwise the server's hostname.
347pub fn linkBase(allocator: std.mem.Allocator, request_host: ?[]const u8, username: []const u8, hostname: []const u8, https: bool) ![]const u8 {
348 const scheme = if (https) "https" else "http";
349 if (request_host) |raw| {
350 const host = try std.ascii.allocLowerString(allocator, std.mem.trim(u8, raw, " \t"));
351 defer allocator.free(host);
352 if (validHost(host)) {
353 const name = if (std.mem.indexOfScalar(u8, host, ':')) |colon| host[0..colon] else host;
354 const domain = mailboxDomain(username, hostname);
355 const tenant = std.mem.startsWith(u8, name, "mail.") and std.ascii.eqlIgnoreCase(name["mail.".len..], domain);
356 if (tenant or std.ascii.eqlIgnoreCase(name, hostname))
357 return std.fmt.allocPrint(allocator, "{s}://{s}", .{ scheme, host });
358 }
359 }
360 return std.fmt.allocPrint(allocator, "{s}://{s}", .{ scheme, hostname });
361}
362
363/// The link itself. The token rides in the fragment, which browsers do not
364/// send to servers, so it stays out of gateway and access logs.
365pub fn resetLink(allocator: std.mem.Allocator, base: []const u8, token: []const u8) ![]const u8 {
366 return std.fmt.allocPrint(allocator, "{s}/reset#token={s}", .{ base, token });
367}
368
369pub const ResetMail = struct {
370 username: []const u8,
371 recovery: []const u8,
372 link: []const u8,
373 ip: ?[]const u8 = null,
374 hostname: []const u8,
375};
376
377pub fn resetMessage(allocator: std.mem.Allocator, m: ResetMail) !compose.Message {
378 const to = try allocator.alloc([]const u8, 1);
379 to[0] = m.recovery;
380 const origin = if (m.ip) |ip| try std.fmt.allocPrint(allocator, "\nThe request came from {s}.\n", .{ip}) else "";
381 return .{
382 .from = try senderFor(allocator, m.username, m.hostname),
383 .to = to,
384 .subject = try std.fmt.allocPrint(allocator, "Reset the password for {s}", .{m.username}),
385 .text_body = try std.fmt.allocPrint(allocator,
386 \\Someone asked to reset the password for the mailbox {s}.
387 \\This address is its recovery address.
388 \\
389 \\To choose a new password, open this link within {d} minutes:
390 \\
391 \\{s}
392 \\
393 \\The link works once. If you did not ask for this, ignore this
394 \\email: the password stays as it is.
395 \\{s}
396 , .{ m.username, @divTrunc(token_ttl_seconds, 60), m.link, origin }),
397 .auto_submitted = true,
398 };
399}
400
401pub fn passwordResetNotice(allocator: std.mem.Allocator, username: []const u8, recovery: []const u8, hostname: []const u8) !compose.Message {
402 const to = try allocator.alloc([]const u8, 1);
403 to[0] = recovery;
404 return .{
405 .from = try senderFor(allocator, username, hostname),
406 .to = to,
407 .subject = try std.fmt.allocPrint(allocator, "The password for {s} was reset", .{username}),
408 .text_body = try std.fmt.allocPrint(allocator,
409 \\The password for the mailbox {s} was just reset with a link
410 \\sent to this address. Every webmail session was signed out, and
411 \\mail apps need the new password.
412 \\
413 \\If this was not you, someone can read this inbox: secure it, then
414 \\ask the mail administrator to reset the mailbox password.
415 \\
416 , .{username}),
417 .auto_submitted = true,
418 };
419}
420
421pub fn recoveryChangedNotice(allocator: std.mem.Allocator, username: []const u8, previous: []const u8, hostname: []const u8) !compose.Message {
422 const to = try allocator.alloc([]const u8, 1);
423 to[0] = previous;
424 return .{
425 .from = try senderFor(allocator, username, hostname),
426 .to = to,
427 .subject = try std.fmt.allocPrint(allocator, "The recovery address for {s} changed", .{username}),
428 .text_body = try std.fmt.allocPrint(allocator,
429 \\This address is no longer the recovery address for the mailbox
430 \\{s}. It was changed in webmail by someone signed in with the
431 \\mailbox password.
432 \\
433 \\If this was not you, ask the mail administrator to reset the
434 \\mailbox password.
435 \\
436 , .{username}),
437 .auto_submitted = true,
438 };
439}
440
441// ── Tests ────────────────────────────────────────────────────────────────────
442
443const testing = std.testing;
444
445fn testDb() !database.Database {
446 return database.Database.init(testing.allocator, ":memory:");
447}
448
449test "validateAddress trims, refuses the mailbox itself and malformed input" {
450 try testing.expectEqualStrings("pawel@outlook.de", try validateAddress("pawel@hq.training", " pawel@outlook.de "));
451 try testing.expectError(RecoveryError.SameAsMailbox, validateAddress("pawel@hq.training", "Pawel@HQ.training"));
452 try testing.expectError(RecoveryError.InvalidAddress, validateAddress("a@b.test", ""));
453 try testing.expectError(RecoveryError.InvalidAddress, validateAddress("a@b.test", "no-at-sign"));
454 try testing.expectError(RecoveryError.InvalidAddress, validateAddress("a@b.test", "x@localhost"));
455 try testing.expectError(RecoveryError.InvalidAddress, validateAddress("a@b.test", "x@y.test\r\nBcc: evil@z.test"));
456 try testing.expectError(RecoveryError.InvalidAddress, validateAddress("a@b.test", "<x@y.test>"));
457}
458
459test "recovery address: set, read back, clear, and provisioning policies" {
460 var db = try testDb();
461 defer db.deinit();
462 const a = testing.allocator;
463 _ = try db.createUser("pawel@hq.test", "hash", "pawel@hq.test");
464
465 const none = (try get(a, &db, "pawel@hq.test")).?;
466 try testing.expect(none.address == null);
467 try testing.expect(none.source == null);
468 try testing.expect((try get(a, &db, "nobody@hq.test")) == null);
469
470 // First deploy sets it; the second is a no-op.
471 try testing.expectEqual(ApplyResult.updated, try apply(a, &db, "pawel@hq.test", "pawel@outlook.test", .initial, 100));
472 try testing.expectEqual(ApplyResult.unchanged, try apply(a, &db, "pawel@hq.test", "PAWEL@outlook.test", .initial, 101));
473 // Config changing an operator-set address is applied even under `initial`.
474 try testing.expectEqual(ApplyResult.updated, try apply(a, &db, "pawel@hq.test", "pawel@gmail.test", .initial, 102));
475
476 // The owner changes it in webmail: `initial` keeps their choice...
477 try set(&db, "pawel@hq.test", "pawel@proton.test", .user, 103);
478 try testing.expectEqual(ApplyResult.kept_owner_choice, try apply(a, &db, "pawel@hq.test", "pawel@gmail.test", .initial, 104));
479 {
480 const r = (try get(a, &db, "pawel@hq.test")).?;
481 defer a.free(r.address.?);
482 try testing.expectEqualStrings("pawel@proton.test", r.address.?);
483 try testing.expectEqual(Source.user, r.source.?);
484 try testing.expectEqual(@as(i64, 103), r.updated_at.?);
485 }
486 // ...and `managed` overrides it.
487 try testing.expectEqual(ApplyResult.updated, try apply(a, &db, "pawel@hq.test", "pawel@gmail.test", .managed, 105));
488 {
489 const r = (try get(a, &db, "pawel@hq.test")).?;
490 defer a.free(r.address.?);
491 try testing.expectEqualStrings("pawel@gmail.test", r.address.?);
492 try testing.expectEqual(Source.operator, r.source.?);
493 }
494 // An owner who clears it is also respected by `initial`.
495 try set(&db, "pawel@hq.test", null, .user, 106);
496 try testing.expectEqual(ApplyResult.kept_owner_choice, try apply(a, &db, "pawel@hq.test", "pawel@gmail.test", .initial, 107));
497 try testing.expect((try get(a, &db, "pawel@hq.test")).?.address == null);
498
499 try testing.expectError(RecoveryError.UserNotFound, apply(a, &db, "nobody@hq.test", "x@y.test", .managed, 108));
500 try testing.expectError(RecoveryError.UserNotFound, set(&db, "nobody@hq.test", "x@y.test", .operator, 108));
501 try testing.expectError(RecoveryError.SameAsMailbox, apply(a, &db, "pawel@hq.test", "pawel@hq.test", .managed, 108));
502}
503
504test "issue answers nothing for unknown, disabled and recovery-less mailboxes" {
505 var db = try testDb();
506 defer db.deinit();
507 const a = testing.allocator;
508 _ = try db.createUser("plain@hq.test", "hash", "plain@hq.test");
509 _ = try db.createUser("off@hq.test", "hash", "off@hq.test");
510 try set(&db, "off@hq.test", "off