From e4506815efce0ff120b7c3aa8442ca98321dfcf2 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 30 Mar 2026 14:42:49 +0000 Subject: [PATCH] . --- .env.example | 1 + README.md | 26 +++--- apps/api/src/lib/config.ts | 1 + apps/api/src/lib/db.ts | 100 +++++++++++++++++++++ apps/api/src/users/service.ts | 93 +++++++++++++------ apps/web/src/App.tsx | 4 +- apps/web/src/components/ResetLinkModal.tsx | 12 ++- apps/web/src/components/UserFormModal.tsx | 2 +- apps/web/src/pages/DashboardPage.tsx | 11 ++- apps/web/src/pages/ResetPasswordPage.tsx | 18 ++-- packages/shared/src/index.ts | 8 +- 11 files changed, 218 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index acd7f91..fb68f2f 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,6 @@ HOSTED_DOMAINS=example.com SESSION_COOKIE_NAME=mail_admin_session SESSION_TTL_MINUTES=480 RESET_TOKEN_TTL_MINUTES=60 +MAILBOX_INVITE_TTL_HOURS=48 APP_ADMIN_EMAIL=admin@local APP_ADMIN_ACCESS_KEY=change-this-access-key diff --git a/README.md b/README.md index 97a6a4a..ae98566 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,7 @@ The backend is intentionally flat: - `apps/api/src/docker/` Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation. - `apps/api/src/users/service.ts` - Contains the mailbox user operations and the one-time link flow for password changes. -- `apps/api/src/mail/service.ts` - Sends setup emails for newly created mailboxes through `sendmail` inside the Docker Mailserver container. + Contains the mailbox user operations, pending mailbox invites, and the one-time link flow for password changes. - `apps/api/src/lib/` Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities. @@ -98,24 +96,24 @@ The dashboard includes: - mailbox listing - simple search - create user modal -- temporary link generation for password changes +- 48-hour setup link generation for new mailboxes +- temporary link generation for password changes on existing mailboxes - delete action - no direct password change from the admin panel -The create flow intentionally generates a temporary password on the server and immediately emails a one-time reset link to the user. That means the admin panel never needs to expose an initial mailbox password in the UI. +The create flow now generates a 48-hour setup link. The mailbox does not exist yet at that point. It is created only when the user opens the link and chooses a password. ## 5. Password reset flow The reset flow is built to stay secure without becoming heavy: -1. An admin generates a one-time link for an existing mailbox. -2. The backend generates a random opaque token. -3. Only the SHA-256 hash of that token is stored in SQLite. -4. The admin shares the one-time link manually with the user. -5. The reset page validates the token before showing the password form. -6. On submit, the token is claimed for one use only. -7. The backend updates the mailbox password through Docker Mailserver. -8. Used or expired tokens are rejected. +1. For a new mailbox, the admin generates a 48-hour setup link. +2. The mailbox is not created yet. +3. The backend stores only the SHA-256 hash of the invite token. +4. The admin shares the one-time setup link manually with the user. +5. When the user chooses a password through the link, the backend creates the mailbox in Docker Mailserver. +6. For existing mailboxes, the same public page is reused for password reset links. +7. Every link is one-time and expires automatically. ## 6. Docker CLI integration strategy @@ -142,6 +140,7 @@ Security choices here: - Access key login is protected by an intentionally aggressive in-memory rate limit with escalating lockouts. - Sessions are server-side, opaque, and stored in an `HttpOnly` cookie with `SameSite=Strict`. - Password reset tokens are random, one-time, time-limited, and stored only as hashes. +- New mailbox setup links are also one-time, time-limited, and stored only as hashes. - The admin panel never changes mailbox passwords directly; it only generates one-time links. - Admin-only routes require a valid session cookie. - Validation is applied to logins, emails, passwords, and reset payloads. @@ -166,6 +165,7 @@ Security choices here: - `MAILSERVER_CONTAINER` - `APP_ADMIN_EMAIL` - `APP_ADMIN_ACCESS_KEY` + - `MAILBOX_INVITE_TTL_HOURS=48` 3. Install dependencies: ```bash diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 9489ee5..45c40e7 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -29,6 +29,7 @@ export const config = { sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session", sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000, resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000, + mailboxInviteTtlMs: parseNumber(env.MAILBOX_INVITE_TTL_HOURS, 48) * 60 * 60_000, appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local", appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "", appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "no-reply@example.com", diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index 75415f9..0aa4dab 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -26,6 +26,15 @@ export type PasswordResetTokenRow = { created_at: number; }; +export type PendingMailboxInviteRow = { + token_hash: string; + user_email: string; + created_by: string; + expires_at: number; + used_at: number | null; + created_at: number; +}; + mkdirSync(dirname(config.dbPath), { recursive: true }); const db = new Database(config.dbPath, { create: true }); @@ -55,6 +64,15 @@ db.exec(` created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS pending_mailbox_invites ( + token_hash TEXT PRIMARY KEY, + user_email TEXT NOT NULL, + created_by TEXT NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at ON app_sessions(expires_at); @@ -63,6 +81,12 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); + + CREATE INDEX IF NOT EXISTS idx_pending_mailbox_invites_email + ON pending_mailbox_invites(user_email); + + CREATE INDEX IF NOT EXISTS idx_pending_mailbox_invites_expires_at + ON pending_mailbox_invites(expires_at); `); const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1"); @@ -115,6 +139,46 @@ const deleteExpiredResetTokensStatement = db.query(` WHERE expires_at <= ?1 OR (used_at IS NOT NULL AND used_at <= ?2) `); +const insertPendingMailboxInviteStatement = db.query(` + INSERT INTO pending_mailbox_invites (token_hash, user_email, created_by, expires_at, used_at, created_at) + VALUES (?1, ?2, ?3, ?4, NULL, ?5) +`); +const getActivePendingMailboxInviteStatement = db.query(` + SELECT * FROM pending_mailbox_invites + WHERE token_hash = ?1 + AND used_at IS NULL + AND expires_at > ?2 +`); +const getActivePendingMailboxInviteForUserStatement = db.query(` + SELECT * FROM pending_mailbox_invites + WHERE user_email = ?1 + AND used_at IS NULL + AND expires_at > ?2 + ORDER BY created_at DESC + LIMIT 1 +`); +const deleteActivePendingMailboxInvitesForUserStatement = db.query(` + DELETE FROM pending_mailbox_invites + WHERE user_email = ?1 + AND used_at IS NULL +`); +const claimPendingMailboxInviteStatement = db.query(` + UPDATE pending_mailbox_invites + SET used_at = ?2 + WHERE token_hash = ?1 + AND used_at IS NULL + AND expires_at > ?3 +`); +const releasePendingMailboxInviteStatement = db.query(` + UPDATE pending_mailbox_invites + SET used_at = NULL + WHERE token_hash = ?1 +`); +const deleteExpiredPendingMailboxInvitesStatement = db.query(` + DELETE FROM pending_mailbox_invites + WHERE expires_at <= ?1 + OR (used_at IS NOT NULL AND used_at <= ?2) +`); export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null; @@ -171,8 +235,44 @@ export const releasePasswordResetToken = (tokenHash: string): void => { releaseResetTokenStatement.run(tokenHash); }; +export const createPendingMailboxInvite = (input: { + tokenHash: string; + userEmail: string; + createdBy: string; + expiresAt: number; +}): void => { + insertPendingMailboxInviteStatement.run( + input.tokenHash, + input.userEmail, + input.createdBy, + input.expiresAt, + Date.now() + ); +}; + +export const getActivePendingMailboxInvite = (tokenHash: string): PendingMailboxInviteRow | null => + (getActivePendingMailboxInviteStatement.get(tokenHash, Date.now()) as PendingMailboxInviteRow | null) ?? null; + +export const getActivePendingMailboxInviteForUser = (userEmail: string): PendingMailboxInviteRow | null => + (getActivePendingMailboxInviteForUserStatement.get(userEmail, Date.now()) as PendingMailboxInviteRow | null) ?? null; + +export const deleteActivePendingMailboxInvitesForUser = (userEmail: string): void => { + deleteActivePendingMailboxInvitesForUserStatement.run(userEmail); +}; + +export const claimPendingMailboxInvite = (tokenHash: string): boolean => { + const usedAt = Date.now(); + const result = claimPendingMailboxInviteStatement.run(tokenHash, usedAt, usedAt); + return result.changes > 0; +}; + +export const releasePendingMailboxInvite = (tokenHash: string): void => { + releasePendingMailboxInviteStatement.run(tokenHash); +}; + export const cleanupDatabase = (): void => { const now = Date.now(); deleteExpiredSessionsStatement.run(now); deleteExpiredResetTokensStatement.run(now, now - 86_400_000); + deleteExpiredPendingMailboxInvitesStatement.run(now, now - 86_400_000); }; diff --git a/apps/api/src/users/service.ts b/apps/api/src/users/service.ts index f746bb8..fd2bd1d 100644 --- a/apps/api/src/users/service.ts +++ b/apps/api/src/users/service.ts @@ -8,15 +8,19 @@ import { import { config } from "../lib/config"; import { claimPasswordResetToken, + claimPendingMailboxInvite, createPasswordResetToken, + createPendingMailboxInvite, deleteActivePasswordResetTokensForUser, - deletePasswordResetToken, + deleteActivePendingMailboxInvitesForUser, getActivePasswordResetToken, + getActivePendingMailboxInvite, + getActivePendingMailboxInviteForUser, + releasePendingMailboxInvite, releasePasswordResetToken } from "../lib/db"; import { HttpError } from "../lib/errors"; -import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security"; -import { sendPasswordResetEmail } from "../mail/service"; +import { createOpaqueToken, hashToken } from "../lib/security"; const maskEmail = (email: string): string => { const [rawLocalPart, rawDomain] = email.split("@"); @@ -32,6 +36,13 @@ const maskEmail = (email: string): string => { const buildResetLink = (token: string): string => `${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`; +const ensureUserDoesNotExist = async (email: string): Promise => { + const users = await listMailUsers(); + if (users.some((item) => item.email === email)) { + throw new HttpError(409, "A mailbox with this email already exists."); + } +}; + const createPasswordResetLink = async ( email: string, createdBy: string @@ -72,29 +83,32 @@ export const listUsers = async (): Promise => listMailUsers(); export const createUser = async (email: string, createdBy: string): Promise => { - const temporaryPassword = createTemporaryPassword(); - await addMailUser(email, temporaryPassword); + await ensureUserDoesNotExist(email); - const user = await ensureUserExists(email); - - try { - const reset = await createPasswordResetLink(email, createdBy); - await sendPasswordResetEmail(email, reset.resetLink, reset.expiresAt); - - return { - user, - resetLinkSent: true, - resetLinkExpiresAt: reset.expiresAt, - warning: null - }; - } catch { - return { - user, - resetLinkSent: false, - resetLinkExpiresAt: null, - warning: "Mailbox created, but the setup email could not be sent. Trigger another reset link after mail delivery is fixed." - }; + const activeInvite = getActivePendingMailboxInviteForUser(email); + if (activeInvite) { + throw new HttpError(409, "A setup link already exists for this mailbox."); } + + deleteActivePendingMailboxInvitesForUser(email); + + const rawToken = createOpaqueToken(); + const tokenHash = hashToken(rawToken); + const expiresAtMs = Date.now() + config.mailboxInviteTtlMs; + const expiresAt = new Date(expiresAtMs).toISOString(); + + createPendingMailboxInvite({ + tokenHash, + userEmail: email, + createdBy, + expiresAt: expiresAtMs + }); + + return { + email, + setupLink: buildResetLink(rawToken), + expiresAt + }; }; export const deleteUser = async (email: string): Promise => { @@ -110,12 +124,24 @@ export const triggerPasswordReset = async ( }; export const inspectPasswordReset = async (token: string): Promise => { - const resetToken = getActivePasswordResetToken(hashToken(token)); + const tokenHash = hashToken(token); + const pendingInvite = getActivePendingMailboxInvite(tokenHash); + + if (pendingInvite) { + return { + mode: "invite", + emailHint: pendingInvite.user_email, + expiresAt: new Date(pendingInvite.expires_at).toISOString() + }; + } + + const resetToken = getActivePasswordResetToken(tokenHash); if (!resetToken) { throw new HttpError(400, "This password reset link is invalid or expired."); } return { + mode: "reset", emailHint: maskEmail(resetToken.user_email), expiresAt: new Date(resetToken.expires_at).toISOString() }; @@ -123,6 +149,23 @@ export const inspectPasswordReset = async (token: string): Promise => { const tokenHash = hashToken(token); + const pendingInvite = getActivePendingMailboxInvite(tokenHash); + + if (pendingInvite) { + const claimedInvite = claimPendingMailboxInvite(tokenHash); + if (!claimedInvite) { + throw new HttpError(400, "This setup link is invalid or already used."); + } + + try { + await addMailUser(pendingInvite.user_email, password); + return; + } catch (error) { + releasePendingMailboxInvite(tokenHash); + throw error; + } + } + const resetToken = getActivePasswordResetToken(tokenHash); if (!resetToken) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e942b72..909acab 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -93,9 +93,7 @@ export default function App() { setErrorMessage(null); try { - const result = await api.createUser({ email }); - await loadUsers(); - return result; + return await api.createUser({ email }); } finally { setBusy(false); } diff --git a/apps/web/src/components/ResetLinkModal.tsx b/apps/web/src/components/ResetLinkModal.tsx index daf722b..23eb377 100644 --- a/apps/web/src/components/ResetLinkModal.tsx +++ b/apps/web/src/components/ResetLinkModal.tsx @@ -1,4 +1,5 @@ type ResetLinkModalProps = { + variant?: "invite" | "reset"; email: string; resetLink: string; expiresAt: string; @@ -6,6 +7,7 @@ type ResetLinkModalProps = { }; export default function ResetLinkModal({ + variant = "reset", email, resetLink, expiresAt, @@ -19,14 +21,18 @@ export default function ResetLinkModal({ } }; + const isInvite = variant === "invite"; + return (
-

Temporary reset link

+

{isInvite ? "Mailbox setup link" : "Temporary reset link"}

- No email was sent for {email}. Share this one-time link yourself. + {isInvite + ? <>Share this one-time setup link for {email}. The mailbox will only be created after the user chooses a password. + : <>No email was sent for {email}. Share this one-time link yourself.}