also looking at this
feat: mailbox recovery addresses and password reset by email
#13
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.
| @@ -208,6 +208,7 @@ Test binaries link against `sqlite3` system library. The argon2 password tests u | ||
| 208 | 208 | ## Common Tasks |
| 209 | 209 | |
| 210 | 210 | - **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" | |
| 211 | 212 | - **Check service**: `systemctl status mail` (on server via SSM) |
| 212 | 213 | - **View IMAP logs**: `journalctl -u mail | grep IMAP` |
| 213 | 214 | - **Backup**: `mail-server backup create` → S3 bucket `stacks-production-s3-backups` |
| @@ -30,6 +30,46 @@ webmail provisioning, aliases, certificate issuance and renewal live in ts-cloud | ||
| 30 | 30 | Outbox records delivery results. A partial retry contains only failed recipients. |
| 31 | 31 | Interrupted dispatch is marked Unknown and requires review before a manual retry. |
| 32 | 32 | |
| 33 | ## Password recovery | |
| 34 | ||
| 35 | Each mailbox may have one recovery address outside itself. "Forgot password?" | |
| 36 | on the sign-in page (`/forgot`) asks for the mailbox address and always answers | |
| 37 | the same way, whether or not the mailbox exists or has a recovery address. When | |
| 38 | it 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 | |
| 40 | DKIM-signed with that domain's key. The link (`/reset#token=...`) works once, | |
| 41 | expires after 30 minutes, and carries its token in the fragment so it never | |
| 42 | reaches a server or gateway log. Setting the new password signs out every | |
| 43 | session and tells the recovery address that the password was reset. | |
| 44 | ||
| 45 | Limits: at most 3 links per mailbox per hour and one per minute (counted in the | |
| 46 | database, so a restart does not reset them), plus a per-IP limit on requests and | |
| 47 | on token attempts. A link is voided by a newer one, by any password change and | |
| 48 | by any change of the recovery address. Only a token's SHA-256 is stored | |
| 49 | (`mailbox_reset_tokens`). | |
| 50 | ||
| 51 | Links point at `mail.<mailbox domain>` or the server hostname when the request | |
| 52 | came in on one of those names, and at `SMTP_HOSTNAME` otherwise. The request's | |
| 53 | Host header alone is never trusted, because a link built from it would carry the | |
| 54 | token to whichever server the requester named. | |
| 55 | ||
| 56 | Signed-in users see and change their recovery address under Account; saving | |
| 57 | asks for the mailbox password again, and the previous address is notified. | |
| 58 | Operators and deploy provisioning use the CLI: | |
| 59 | ||
| 60 | ```bash | |
| 61 | mail user:local recovery pawel@example.com # show | |
| 62 | mail user:local recovery pawel@example.com pawel@elsewhere.example # set (policy managed) | |
| 63 | mail user:local recovery pawel@example.com pawel@elsewhere.example --policy initial | |
| 64 | mail 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 | |
| 69 | their own; `--policy managed` always sets it. The command prints one | |
| 70 | `Recovery: updated|unchanged|kept|cleared` line and exits non-zero for an | |
| 71 | unknown mailbox or an unusable address. | |
| 72 | ||
| 33 | 73 | ## Configuration |
| 34 | 74 | |
| 35 | 75 | Declare `infrastructure.compute.managedServices.mail.webmail` in a ts-cloud |
| @@ -80,6 +120,14 @@ revokes existing webmail sessions. | ||
| 80 | 120 | `delaySeconds` controls the hold. Replaying the same ID cannot dispatch twice. |
| 81 | 121 | `GET /webmail/api/outbox` exposes results; `DELETE /webmail/api/outbox/:id` |
| 82 | 122 | 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. | |
| 83 | 131 | - Existing immediate-compose and message endpoints remain compatible with the |
| 84 | 132 | native SDK and older clients. New clients use the durable draft and queue APIs. |
| 85 | 133 | |
| @@ -9,13 +9,13 @@ test('deployments fingerprint styles so existing browsers fetch updated controls | ||
| 9 | 9 | expect(stylesheetUrl(first)).toBe(stylesheetUrl(first)) |
| 10 | 10 | expect(stylesheetUrl(first)).not.toBe(stylesheetUrl(second)) |
| 11 | 11 | await buildWebmail() |
| 12 | for (const page of ['index', 'login']) { | |
| 12 | for (const page of ['index', 'login', 'recover']) { | |
| 13 | 13 | const html = await Bun.file(join(import.meta.dir, `../zig/src/api/webmail_dist/${page}.html`)).text() |
| 14 | 14 | expect(html).toMatch(/href="\/styles\.css\?v=[0-9a-f]{16}"/) |
| 15 | 15 | } |
| 16 | 16 | }) |
| 17 | 17 | |
| 18 | for (const page of ['login', 'index']) { | |
| 18 | for (const page of ['login', 'index', 'recover']) { | |
| 19 | 19 | test(`${page} compiles with STX bindings and a self-contained runtime`, async () => { |
| 20 | 20 | const path = join(import.meta.dir, `pages/${page}.stx`) |
| 21 | 21 | const source = await Bun.file(path).text() |
| @@ -31,6 +31,14 @@ for (const page of ['login', 'index']) { | ||
| 31 | 31 | if (page === 'index') { |
| 32 | 32 | expect(html).toContain('sandbox=""') |
| 33 | 33 | 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') | |
| 34 | 42 | } |
| 35 | 43 | }) |
| 36 | 44 | } |
| @@ -13,7 +13,7 @@ export async function buildWebmail(): Promise<void> { | ||
| 13 | 13 | const dist = join(root, '../zig/src/api/webmail_dist') |
| 14 | 14 | const styles = await readFile(join(root, 'public/styles.css')) |
| 15 | 15 | await mkdir(dist, { recursive: true }) |
| 16 | for (const page of ['index', 'login']) { | |
| 16 | for (const page of ['index', 'login', 'recover']) { | |
| 17 | 17 | const html = await renderTemplate(join(root, `pages/${page}.stx`), { injectCSS: true }) |
| 18 | 18 | if (!html.includes('data-stx') || html.includes('<script client>')) |
| 19 | 19 | throw new Error(`STX did not compile ${page}`) |
| @@ -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. */ | |
| 7 | const 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 | */ | |
| 14 | export 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 | ||
| 20 | export const MIN_PASSWORD_LENGTH = 12 | |
| 21 | ||
| 22 | /** Why a new password cannot be submitted, or '' when it can. */ | |
| 23 | export 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 | */ | |
| 35 | export 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. */ | |
| 44 | export 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 | } | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import type { createMailApi } from './mail-api' | |
| 2 | import { state } from '@stacksjs/stx' | |
| 3 | import { recoveryAddressProblem } from './recovery' | |
| 4 | ||
| 5 | /** What GET/PUT /webmail/auth/recovery return. */ | |
| 6 | export 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 | ||
| 12 | type 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 | */ | |
| 19 | export 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 | } | |
| @@ -5,6 +5,7 @@ import { dateBoundary, folderName, fullDate, messageKey, rangeSelection, readabl | ||
| 5 | 5 | import { useAttachments } from './use-attachments' |
| 6 | 6 | import { useDraftAutosave } from './use-draft-autosave' |
| 7 | 7 | import { useRecipientEditor } from './use-recipient-editor' |
| 8 | import { useRecoverySettings } from './use-recovery-settings' | |
| 8 | 9 | |
| 9 | 10 | interface Ref<T> { current: T | null } |
| 10 | 11 | export interface WebmailPorts { |
| @@ -110,6 +111,7 @@ export function useWebmail(ports: WebmailPorts) { | ||
| 110 | 111 | } |
| 111 | 112 | const api = createMailApi(() => user()?.username, onAuthError) |
| 112 | 113 | const recipientEditor = useRecipientEditor(value => api<Contact[]>(`/webmail/api/contacts?q=${encodeURIComponent(value)}`)) |
| 114 | const recoverySettings = useRecoverySettings(api, () => user()?.username || '') | |
| 113 | 115 | const attachmentEditor = useAttachments(() => user()?.username, limits, cause => { if (cause instanceof MailApiError && cause.status === 401) onAuthError(cause) }, id => api(`/webmail/api/uploads/${id}`, { method: 'DELETE' })) |
| 114 | 116 | 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())) |
| 115 | 117 | function content() { |
| @@ -547,7 +549,7 @@ export function useWebmail(ports: WebmailPorts) { | ||
| 547 | 549 | try { if (hasComposition()) await draft.flushAll(); await api('/webmail/auth/logout', { method: 'POST', body: '{}' }); resetComposition(); ports.navigate('/login', { reload: true }) } |
| 548 | 550 | catch (cause) { error.set(errorMessage(cause)) } |
| 549 | 551 | } |
| 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() }) } | |
| 551 | 553 | async function changePassword(): Promise<void> { |
| 552 | 554 | if (savingPassword()) return |
| 553 | 555 | passwordError.set('') |
| @@ -604,7 +606,7 @@ export function useWebmail(ports: WebmailPorts) { | ||
| 604 | 606 | } |
| 605 | 607 | function preventUnload(event: BeforeUnloadEvent): void { if (hasUnsavedChanges()) { event.preventDefault(); event.returnValue = '' } } |
| 606 | 608 | 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 } | |
| 608 | 610 | } |
| 609 | 611 | |
| 610 | 612 | function errorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' } |
| @@ -24,6 +24,8 @@ const { | ||
| 24 | 24 | composing, title, subject, body, showCc, showBcc, sending, sendUncertain, composeError, hasComposition, saveStatus, |
| 25 | 25 | savingDraft, draftError, draftConflict, sendDelay, sendDelayOptions, settings, currentPassword, newPassword, |
| 26 | 26 | confirmPassword, passwordError, savingPassword, confirmation, confirming, limits, |
| 27 | recoveryAddress, recoverySetBy, recoveryInput, recoveryPassword, recoveryError, recoveryNotice, recoveryLoading, | |
| 28 | savingRecovery, saveRecovery, | |
| 27 | 29 | toRecipients, ccRecipients, bccRecipients, toInput, ccInput, bccInput, recipientFocus, suggestions, |
| 28 | 30 | suggestionIndex, recipientSearching, recipientErrors, lookup, chooseContact, removeRecipient, |
| 29 | 31 | editRecipient, recipientKeydown, recipientBlur, attachments, dragging, attachmentBytes, |
| @@ -55,7 +57,7 @@ onUnmounted(() => mail.destroy()) | ||
| 55 | 57 | <button class="wm-folder" :class="{ 'wm-folder--active': !outboxView && folder === '*' }" @click="selectFolder('*')" :disabled="mutating">All mail</button> |
| 56 | 58 | <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> |
| 57 | 59 | </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> | |
| 59 | 61 | </aside> |
| 60 | 62 | <section class="wm-workspace"> |
| 61 | 63 | <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()) | ||
| 162 | 164 | <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> |
| 163 | 165 | </form> |
| 164 | 166 | </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> | |
| 166 | 169 | <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> |
| 167 | 170 | </body> |
| 168 | 171 | </html> |
| @@ -42,6 +42,7 @@ async function signIn() { | ||
| 42 | 42 | <p :if="error" :text="error" class="login-error" role="alert"></p> |
| 43 | 43 | <button type="submit" :disabled="busy" :text="busy ? 'Signing in…' : 'Sign in'"></button> |
| 44 | 44 | </form> |
| 45 | <p class="login-forgot"><a class="login-link" href="/forgot">Forgot password?</a></p> | |
| 45 | 46 | <noscript>JavaScript is required to use webmail.</noscript> |
| 46 | 47 | </main> |
| 47 | 48 | </body> |
| @@ -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> | |
| 12 | import { 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 | |
| 15 | const mode = state('request') | |
| 16 | const address = state('') | |
| 17 | const account = state('') | |
| 18 | const token = state('') | |
| 19 | const password = state('') | |
| 20 | const confirmation = state('') | |
| 21 | const busy = state(false) | |
| 22 | const error = state('') | |
| 23 | ||
| 24 | async 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 | ||
| 34 | async 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 | ||
| 48 | async 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 | ||
| 62 | async 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 | ||
| 78 | function startOver() { error.set(''); token.set(''); mode.set('request') } | |
| 79 | ||
| 80 | onMount(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> | |
| @@ -56,6 +56,12 @@ body { | ||
| 56 | 56 | cursor: pointer; |
| 57 | 57 | } |
| 58 | 58 | .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; } | |
| 59 | 65 | .login-error { |
| 60 | 66 | margin: 12px 0 0; |
| 61 | 67 | color: #c0392b; |
| @@ -0,0 +1,41 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { newPasswordProblem, recoveryAddressProblem, resetErrorMessage, tokenFromHash } from './functions/recovery' | |
| 3 | ||
| 4 | const token = 'ab'.repeat(32) | |
| 5 | ||
| 6 | describe('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 | ||
| 23 | describe('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 | ||
| 32 | describe('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 | }) | |
| @@ -21,7 +21,7 @@ async function main(): Promise<void> { | ||
| 21 | 21 | } |
| 22 | 22 | catch { return Response.json({ message: 'Mail API is unavailable.' }, { status: 502 }) } |
| 23 | 23 | } |
| 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' } | |
| 25 | 25 | const file = assets[url.pathname] |
| 26 | 26 | return file ? new Response(Bun.file(`${dist}/${file}`)) : new Response('Not found', { status: 404 }) |
| 27 | 27 | }, |
| @@ -66,6 +66,9 @@ pub const Message = struct { | ||
| 66 | 66 | attachments: []const Attachment = &.{}, |
| 67 | 67 | /// Only drafts and the sender's private Sent copy retain Bcc headers. |
| 68 | 68 | 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, | |
| 69 | 72 | }; |
| 70 | 73 | |
| 71 | 74 | pub const SendConfig = struct { |
| @@ -273,6 +276,7 @@ pub fn buildMime(allocator: std.mem.Allocator, msg: Message, ts: i64, msgid: []c | ||
| 273 | 276 | try w.print("Message-ID: {s}\r\n", .{msgid}); |
| 274 | 277 | if (msg.in_reply_to.len > 0) try w.print("In-Reply-To: {s}\r\n", .{msg.in_reply_to}); |
| 275 | 278 | 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"); | |
| 276 | 280 | try w.writeAll("MIME-Version: 1.0\r\n"); |
| 277 | 281 | |
| 278 | 282 | const has_attachments = msg.attachments.len > 0; |
| @@ -501,6 +505,18 @@ pub fn send(allocator: std.mem.Allocator, msg: Message, cfg: SendConfig) !SendRe | ||
| 501 | 505 | return .{ .message_id = msgid, .delivered = delivered, .failed = try failed.toOwnedSlice(allocator), .sent_saved = sent_saved }; |
| 502 | 506 | } |
| 503 | 507 | |
| 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. | |
| 511 | pub 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 | ||
| 504 | 520 | // ── Tests ──────────────────────────────────────────────────────────────────── |
| 505 | 521 | |
| 506 | 522 | test "isSafeAddress rejects injection, path traversal, and malformed" { |
| @@ -8,6 +8,12 @@ | ||
| 8 | 8 | //! POST /webmail/auth/login {username,password} -> sets session cookie |
| 9 | 9 | //! POST /webmail/auth/logout -> clears cookie |
| 10 | 10 | //! 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 | |
| 11 | 17 | //! GET /webmail/api/folders -> [{name,total,unread}] |
| 12 | 18 | //! GET /webmail/api/messages?folder=&page=&per_page= -> {items,total,page} |
| 13 | 19 | //! GET /webmail/api/messages/:uid?folder= -> {message} |
| @@ -41,12 +47,14 @@ const store = ; | ||
| 41 | 47 | const threads = ; |
| 42 | 48 | const ascii_compat = ; |
| 43 | 49 | const caldav_store = ; |
| 50 | const recovery = ; | |
| 44 | 51 | |
| 45 | 52 | // SPA static assets, rendered by packages/webmail/build.ts into webmail_dist/ |
| 46 | 53 | // (gitignored, regenerated by `zig build`) and baked into the binary so the whole |
| 47 | 54 | // webmail UI ships as one executable — same single-binary model as admin.html. |
| 48 | 55 | const index_html = @embedFile("webmail_dist/index.html"); |
| 49 | 56 | const login_html = @embedFile("webmail_dist/login.html"); |
| 57 | const recover_html = @embedFile("webmail_dist/recover.html"); | |
| 50 | 58 | const styles_css = @embedFile("webmail_dist/styles.css"); |
| 51 | 59 | |
| 52 | 60 | pub const WebmailHttpConfig = struct { |
| @@ -86,6 +94,10 @@ pub const WebmailHttpServer = struct { | ||
| 86 | 94 | /// Throttles login attempts per IP to blunt credential brute-forcing. |
| 87 | 95 | /// Its own internal mutex makes it safe to share across connection threads. |
| 88 | 96 | 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, | |
| 89 | 101 | /// Loaded once at init when TLS is enabled (shared read-only across threads). |
| 90 | 102 | cert_key_pair: ?tls.config.CertKeyPair = null, |
| 91 | 103 | listener: ?socket.Server = null, |
| @@ -125,6 +137,7 @@ pub const WebmailHttpServer = struct { | ||
| 125 | 137 | config.login_rate_max, |
| 126 | 138 | config.login_rate_window_seconds, |
| 127 | 139 | ), |
| 140 | .reset_limiter = security.RateLimiter.init(allocator, 15 * 60, 10, 30, 15 * 60), | |
| 128 | 141 | .running = std.atomic.Value(bool).init(false), |
| 129 | 142 | }; |
| 130 | 143 | if (config.enable_tls and config.tls_cert_path != null and config.tls_key_path != null) { |
| @@ -145,6 +158,7 @@ pub const WebmailHttpServer = struct { | ||
| 145 | 158 | pub fn deinit(self: *WebmailHttpServer) void { |
| 146 | 159 | self.stop(); |
| 147 | 160 | self.login_limiter.deinit(); |
| 161 | self.reset_limiter.deinit(); | |
| 148 | 162 | } |
| 149 | 163 | |
| 150 | 164 | pub fn start(self: *WebmailHttpServer) !void { |
| @@ -455,6 +469,21 @@ fn handleConnection(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena | ||
| 455 | 469 | if (std.mem.eql(u8, p, "/webmail/auth/me") and std.mem.eql(u8, req.method, "GET")) { |
| 456 | 470 | return handleMe(server, conn, arena, req); |
| 457 | 471 | } |
| 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 | } | |
| 458 | 487 | if (std.mem.eql(u8, p, "/webmail/api/compose") and std.mem.eql(u8, req.method, "POST")) { |
| 459 | 488 | return handleCompose(server, conn, arena, req); |
| 460 | 489 | } |
| @@ -495,6 +524,10 @@ fn handleConnection(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena | ||
| 495 | 524 | if (std.mem.eql(u8, p, "/login")) { |
| 496 | 525 | return serveAsset(conn, arena, login_html, "text/html; charset=utf-8"); |
| 497 | 526 | } |
| 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 | } | |
| 498 | 531 | if (std.mem.eql(u8, p, "/styles.css")) { |
| 499 | 532 | return serveAsset(conn, arena, styles_css, "text/css; charset=utf-8"); |
| 500 | 533 | } |
| @@ -515,6 +548,9 @@ fn serveAsset(conn: *webmail_tls.Stream, arena: std.mem.Allocator, body: []const | ||
| 515 | 548 | try w.writeAll("X-Frame-Options: DENY\r\n"); |
| 516 | 549 | if (std.mem.indexOf(u8, content_type, "text/html") != null) { |
| 517 | 550 | 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"); | |
| 518 | 554 | } else { |
| 519 | 555 | try w.writeAll("Cache-Control: public, max-age=3600\r\n"); |
| 520 | 556 | } |
| @@ -681,6 +717,266 @@ fn handleMe(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.me | ||
| 681 | 717 | try sendJson(conn, arena, 200, body.items, null); |
| 682 | 718 | } |
| 683 | 719 | |
| 720 | // ── Password reset and recovery address ───────────────────────────────────── | |
| 721 | ||
| 722 | fn 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. | |
| 727 | fn 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. | |
| 733 | fn publicHttps(server: *const WebmailHttpServer) bool { | |
| 734 | return server.config.enable_tls or server.config.secure_cookies; | |
| 735 | } | |
| 736 | ||
| 737 | const 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. | |
| 750 | fn 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 | ||
| 769 | fn 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 | ||
| 791 | const reset_accepted = "{\"ok\":true}"; | |
| 792 | ||
| 793 | /// A server-written message plus the mailbox it is about, both in a job arena. | |
| 794 | const 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. | |
| 802 | fn 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 | ||
| 849 | fn 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 | ||
| 856 | const 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. | |
| 861 | fn 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. | |
| 879 | fn 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 | ||
| 911 | fn 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} | |
| 922 | fn 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. | |
| 935 | fn 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 | ||
| 684 | 980 | const ComposePayload = service_mod.Payload; |
| 685 | 981 | |
| 686 | 982 | fn handleDraftSave(server: *WebmailHttpServer, conn: *webmail_tls.Stream, arena: std.mem.Allocator, req: Request) !void { |
| @@ -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 | ||
| 20 | const std = ; | |
| 21 | const database = ; | |
| 22 | const auth_mod = ; | |
| 23 | const io_compat = ; | |
| 24 | const compose = ; | |
| 25 | ||
| 26 | /// How long a reset link stays usable. | |
| 27 | pub const token_ttl_seconds: i64 = 30 * 60; | |
| 28 | /// At most this many reset emails per mailbox per hour, whoever asks. | |
| 29 | pub const max_links_per_hour: i64 = 3; | |
| 30 | /// And never two within this many seconds of each other. | |
| 31 | pub const min_link_interval_seconds: i64 = 60; | |
| 32 | /// Token length on the wire: 32 bytes as lowercase hex. | |
| 33 | pub const token_hex_len = 64; | |
| 34 | /// The bounds the webmail password change already enforces. | |
| 35 | pub const min_password_len = 12; | |
| 36 | pub const max_password_len = 1024; | |
| 37 | ||
| 38 | pub const RecoveryError = error{ | |
| 39 | InvalidAddress, | |
| 40 | SameAsMailbox, | |
| 41 | UserNotFound, | |
| 42 | }; | |
| 43 | ||
| 44 | pub 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 | ||
| 55 | pub 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 | ||
| 68 | pub 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. | |
| 80 | pub 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. | |
| 92 | pub 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. | |
| 108 | pub 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 | ||
| 123 | pub 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. | |
| 134 | pub 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. | |
| 149 | pub 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 | ||
| 155 | fn 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. | |
| 167 | pub 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 | ||
| 173 | pub 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. | |
| 187 | pub 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. | |
| 253 | pub 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 | ||
| 267 | pub 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. | |
| 278 | pub 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. | |
| 320 | fn 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. | |
| 328 | pub 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 | ||
| 332 | fn 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. | |
| 347 | pub 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. | |
| 365 | pub 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 | ||
| 369 | pub 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 | ||
| 377 | pub 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 | ||
| 401 | pub 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 | ||
| 421 | pub 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 | ||
| 443 | const testing = std.testing; | |
| 444 | ||
| 445 | fn testDb() !database.Database { | |
| 446 | return database.Database.init(testing.allocator, ":memory:"); | |
| 447 | } | |
| 448 | ||
| 449 | test "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 | ||
| 459 | test "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 | ||
| 504 | test "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 | |