This commit is contained in:
Debian
2026-03-30 14:42:49 +00:00
parent 44ccbd92bd
commit e4506815ef
11 changed files with 218 additions and 58 deletions
+1
View File
@@ -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
+13 -13
View File
@@ -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
+1
View File
@@ -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() ?? "[email protected]",
+100
View File
@@ -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);
};
+68 -25
View File
@@ -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<void> => {
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<MailUser[]> =>
listMailUsers();
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
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<void> => {
@@ -110,12 +124,24 @@ export const triggerPasswordReset = async (
};
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
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<PasswordReset
export const consumePasswordReset = async (token: string, password: string): Promise<void> => {
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) {
+1 -3
View File
@@ -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);
}
+9 -3
View File
@@ -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 (
<div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-header">
<div>
<h2>Temporary reset link</h2>
<h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
<p>
No email was sent for <strong>{email}</strong>. Share this one-time link yourself.
{isInvite
? <>Share this one-time setup link for <strong>{email}</strong>. The mailbox will only be created after the user chooses a password.</>
: <>No email was sent for <strong>{email}</strong>. Share this one-time link yourself.</>}
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose}>
@@ -36,7 +42,7 @@ export default function ResetLinkModal({
<div className="stack">
<label className="field">
<span>One-time link</span>
<span>{isInvite ? "Setup link" : "One-time link"}</span>
<input type="text" readOnly value={resetLink} />
</label>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function UserFormModal({
<div>
<h2>Create user</h2>
<p>
A temporary password will be generated and a reset link will be emailed automatically.
A 48-hour setup link will be generated. The mailbox will only be created after the user chooses a password through that link.
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
+7 -4
View File
@@ -55,10 +55,12 @@ export default function DashboardPage({
try {
const result = await onCreateUser(email);
setCreateOpen(false);
setInlineMessage(
result.warning ??
`Mailbox created and reset email sent. Link expires at ${result.resetLinkExpiresAt}.`
);
setResetLinkData({
email: result.email,
resetLink: result.setupLink,
expiresAt: result.expiresAt
});
setInlineMessage(`Setup link generated for ${result.email}. The mailbox will be created only after the user sets a password.`);
} finally {
setModalBusy(false);
}
@@ -199,6 +201,7 @@ export default function DashboardPage({
{resetLinkData ? (
<ResetLinkModal
variant="invite"
email={resetLinkData.email}
resetLink={resetLinkData.resetLink}
expiresAt={resetLinkData.expiresAt}
+13 -5
View File
@@ -7,6 +7,7 @@ type ResetPasswordPageProps = {
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
const [mode, setMode] = useState<"invite" | "reset">("reset");
const [emailHint, setEmailHint] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [password, setPassword] = useState("");
@@ -23,6 +24,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
if (cancelled) {
return;
}
setMode(result.mode);
setEmailHint(result.emailHint);
setExpiresAt(result.expiresAt);
setStatus("ready");
@@ -73,15 +75,17 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
return (
<main className="auth-shell">
<section className="auth-card">
<div className="eyebrow">Secure reset</div>
<h1>Choose a new mailbox password</h1>
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
<h1>{mode === "invite" ? "Create your mailbox password" : "Choose a new mailbox password"}</h1>
{status === "loading" ? <p className="auth-copy">Checking your reset link…</p> : null}
{status === "invalid" ? (
<>
<p className="auth-copy">
This link is no longer valid. Ask your administrator for a new reset email.
{mode === "invite"
? "This setup link is no longer valid. Ask your administrator for a new setup link."
: "This link is no longer valid. Ask your administrator for a new reset link."}
</p>
{error ? <p className="form-error">{error}</p> : null}
</>
@@ -89,14 +93,18 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
{status === "done" ? (
<p className="notice success">
Your mailbox password has been updated. You can close this page and sign in with the new password.
{mode === "invite"
? "Your mailbox has been created. You can close this page and sign in with the password you just chose."
: "Your mailbox password has been updated. You can close this page and sign in with the new password."}
</p>
) : null}
{status === "ready" ? (
<>
<p className="auth-copy">
Resetting the password for <strong>{emailHint}</strong>. This link expires at {expiresAt}.
{mode === "invite"
? <>Creating the mailbox for <strong>{emailHint}</strong>. This setup link expires at {expiresAt}.</>
: <>Resetting the password for <strong>{emailHint}</strong>. This link expires at {expiresAt}.</>}
</p>
<form className="stack" onSubmit={handleSubmit}>
+4 -4
View File
@@ -22,10 +22,9 @@ export type CreateUserRequest = {
};
export type CreateUserResponse = {
user: MailUser;
resetLinkSent: boolean;
resetLinkExpiresAt: string | null;
warning: string | null;
email: string;
setupLink: string;
expiresAt: string;
};
export type LoginRequest = {
@@ -42,6 +41,7 @@ export type PasswordResetInspectRequest = {
};
export type PasswordResetInspectResponse = {
mode: "invite" | "reset";
emailHint: string;
expiresAt: string;
};