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_COOKIE_NAME=mail_admin_session
SESSION_TTL_MINUTES=480 SESSION_TTL_MINUTES=480
RESET_TOKEN_TTL_MINUTES=60 RESET_TOKEN_TTL_MINUTES=60
MAILBOX_INVITE_TTL_HOURS=48
APP_ADMIN_EMAIL=admin@local APP_ADMIN_EMAIL=admin@local
APP_ADMIN_ACCESS_KEY=change-this-access-key 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/` - `apps/api/src/docker/`
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation. Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
- `apps/api/src/users/service.ts` - `apps/api/src/users/service.ts`
Contains the mailbox user operations and the one-time link flow for password changes. Contains the mailbox user operations, pending mailbox invites, 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.
- `apps/api/src/lib/` - `apps/api/src/lib/`
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities. Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
@@ -98,24 +96,24 @@ The dashboard includes:
- mailbox listing - mailbox listing
- simple search - simple search
- create user modal - 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 - delete action
- no direct password change from the admin panel - 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 ## 5. Password reset flow
The reset flow is built to stay secure without becoming heavy: The reset flow is built to stay secure without becoming heavy:
1. An admin generates a one-time link for an existing mailbox. 1. For a new mailbox, the admin generates a 48-hour setup link.
2. The backend generates a random opaque token. 2. The mailbox is not created yet.
3. Only the SHA-256 hash of that token is stored in SQLite. 3. The backend stores only the SHA-256 hash of the invite token.
4. The admin shares the one-time link manually with the user. 4. The admin shares the one-time setup link manually with the user.
5. The reset page validates the token before showing the password form. 5. When the user chooses a password through the link, the backend creates the mailbox in Docker Mailserver.
6. On submit, the token is claimed for one use only. 6. For existing mailboxes, the same public page is reused for password reset links.
7. The backend updates the mailbox password through Docker Mailserver. 7. Every link is one-time and expires automatically.
8. Used or expired tokens are rejected.
## 6. Docker CLI integration strategy ## 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. - 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`. - 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. - 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. - The admin panel never changes mailbox passwords directly; it only generates one-time links.
- Admin-only routes require a valid session cookie. - Admin-only routes require a valid session cookie.
- Validation is applied to logins, emails, passwords, and reset payloads. - Validation is applied to logins, emails, passwords, and reset payloads.
@@ -166,6 +165,7 @@ Security choices here:
- `MAILSERVER_CONTAINER` - `MAILSERVER_CONTAINER`
- `APP_ADMIN_EMAIL` - `APP_ADMIN_EMAIL`
- `APP_ADMIN_ACCESS_KEY` - `APP_ADMIN_ACCESS_KEY`
- `MAILBOX_INVITE_TTL_HOURS=48`
3. Install dependencies: 3. Install dependencies:
```bash ```bash
+1
View File
@@ -29,6 +29,7 @@ export const config = {
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session", sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000, sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 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", appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local",
appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "", appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "",
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]", appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
+100
View File
@@ -26,6 +26,15 @@ export type PasswordResetTokenRow = {
created_at: number; 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 }); mkdirSync(dirname(config.dbPath), { recursive: true });
const db = new Database(config.dbPath, { create: true }); const db = new Database(config.dbPath, { create: true });
@@ -55,6 +64,15 @@ db.exec(`
created_at INTEGER NOT NULL 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 CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
ON 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 CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
ON 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"); const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
@@ -115,6 +139,46 @@ const deleteExpiredResetTokensStatement = db.query(`
WHERE expires_at <= ?1 WHERE expires_at <= ?1
OR (used_at IS NOT NULL AND used_at <= ?2) 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 => export const getAdmin = (email: string): AdminRow | null =>
(getAdminStatement.get(email) as AdminRow | null) ?? null; (getAdminStatement.get(email) as AdminRow | null) ?? null;
@@ -171,8 +235,44 @@ export const releasePasswordResetToken = (tokenHash: string): void => {
releaseResetTokenStatement.run(tokenHash); 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 => { export const cleanupDatabase = (): void => {
const now = Date.now(); const now = Date.now();
deleteExpiredSessionsStatement.run(now); deleteExpiredSessionsStatement.run(now);
deleteExpiredResetTokensStatement.run(now, now - 86_400_000); 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 { config } from "../lib/config";
import { import {
claimPasswordResetToken, claimPasswordResetToken,
claimPendingMailboxInvite,
createPasswordResetToken, createPasswordResetToken,
createPendingMailboxInvite,
deleteActivePasswordResetTokensForUser, deleteActivePasswordResetTokensForUser,
deletePasswordResetToken, deleteActivePendingMailboxInvitesForUser,
getActivePasswordResetToken, getActivePasswordResetToken,
getActivePendingMailboxInvite,
getActivePendingMailboxInviteForUser,
releasePendingMailboxInvite,
releasePasswordResetToken releasePasswordResetToken
} from "../lib/db"; } from "../lib/db";
import { HttpError } from "../lib/errors"; import { HttpError } from "../lib/errors";
import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security"; import { createOpaqueToken, hashToken } from "../lib/security";
import { sendPasswordResetEmail } from "../mail/service";
const maskEmail = (email: string): string => { const maskEmail = (email: string): string => {
const [rawLocalPart, rawDomain] = email.split("@"); const [rawLocalPart, rawDomain] = email.split("@");
@@ -32,6 +36,13 @@ const maskEmail = (email: string): string => {
const buildResetLink = (token: string): string => const buildResetLink = (token: string): string =>
`${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`; `${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 ( const createPasswordResetLink = async (
email: string, email: string,
createdBy: string createdBy: string
@@ -72,29 +83,32 @@ export const listUsers = async (): Promise<MailUser[]> =>
listMailUsers(); listMailUsers();
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => { export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
const temporaryPassword = createTemporaryPassword(); await ensureUserDoesNotExist(email);
await addMailUser(email, temporaryPassword);
const user = await ensureUserExists(email); const activeInvite = getActivePendingMailboxInviteForUser(email);
if (activeInvite) {
try { throw new HttpError(409, "A setup link already exists for this mailbox.");
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."
};
} }
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> => { export const deleteUser = async (email: string): Promise<void> => {
@@ -110,12 +124,24 @@ export const triggerPasswordReset = async (
}; };
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => { 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) { if (!resetToken) {
throw new HttpError(400, "This password reset link is invalid or expired."); throw new HttpError(400, "This password reset link is invalid or expired.");
} }
return { return {
mode: "reset",
emailHint: maskEmail(resetToken.user_email), emailHint: maskEmail(resetToken.user_email),
expiresAt: new Date(resetToken.expires_at).toISOString() 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> => { export const consumePasswordReset = async (token: string, password: string): Promise<void> => {
const tokenHash = hashToken(token); 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); const resetToken = getActivePasswordResetToken(tokenHash);
if (!resetToken) { if (!resetToken) {
+1 -3
View File
@@ -93,9 +93,7 @@ export default function App() {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await api.createUser({ email }); return await api.createUser({ email });
await loadUsers();
return result;
} finally { } finally {
setBusy(false); setBusy(false);
} }
+9 -3
View File
@@ -1,4 +1,5 @@
type ResetLinkModalProps = { type ResetLinkModalProps = {
variant?: "invite" | "reset";
email: string; email: string;
resetLink: string; resetLink: string;
expiresAt: string; expiresAt: string;
@@ -6,6 +7,7 @@ type ResetLinkModalProps = {
}; };
export default function ResetLinkModal({ export default function ResetLinkModal({
variant = "reset",
email, email,
resetLink, resetLink,
expiresAt, expiresAt,
@@ -19,14 +21,18 @@ export default function ResetLinkModal({
} }
}; };
const isInvite = variant === "invite";
return ( return (
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true"> <div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2>Temporary reset link</h2> <h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
<p> <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> </p>
</div> </div>
<button type="button" className="ghost-button" onClick={onClose}> <button type="button" className="ghost-button" onClick={onClose}>
@@ -36,7 +42,7 @@ export default function ResetLinkModal({
<div className="stack"> <div className="stack">
<label className="field"> <label className="field">
<span>One-time link</span> <span>{isInvite ? "Setup link" : "One-time link"}</span>
<input type="text" readOnly value={resetLink} /> <input type="text" readOnly value={resetLink} />
</label> </label>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function UserFormModal({
<div> <div>
<h2>Create user</h2> <h2>Create user</h2>
<p> <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> </p>
</div> </div>
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}> <button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
+7 -4
View File
@@ -55,10 +55,12 @@ export default function DashboardPage({
try { try {
const result = await onCreateUser(email); const result = await onCreateUser(email);
setCreateOpen(false); setCreateOpen(false);
setInlineMessage( setResetLinkData({
result.warning ?? email: result.email,
`Mailbox created and reset email sent. Link expires at ${result.resetLinkExpiresAt}.` 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 { } finally {
setModalBusy(false); setModalBusy(false);
} }
@@ -199,6 +201,7 @@ export default function DashboardPage({
{resetLinkData ? ( {resetLinkData ? (
<ResetLinkModal <ResetLinkModal
variant="invite"
email={resetLinkData.email} email={resetLinkData.email}
resetLink={resetLinkData.resetLink} resetLink={resetLinkData.resetLink}
expiresAt={resetLinkData.expiresAt} expiresAt={resetLinkData.expiresAt}
+13 -5
View File
@@ -7,6 +7,7 @@ type ResetPasswordPageProps = {
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) { export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading"); const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
const [mode, setMode] = useState<"invite" | "reset">("reset");
const [emailHint, setEmailHint] = useState(""); const [emailHint, setEmailHint] = useState("");
const [expiresAt, setExpiresAt] = useState(""); const [expiresAt, setExpiresAt] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -23,6 +24,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
if (cancelled) { if (cancelled) {
return; return;
} }
setMode(result.mode);
setEmailHint(result.emailHint); setEmailHint(result.emailHint);
setExpiresAt(result.expiresAt); setExpiresAt(result.expiresAt);
setStatus("ready"); setStatus("ready");
@@ -73,15 +75,17 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
return ( return (
<main className="auth-shell"> <main className="auth-shell">
<section className="auth-card"> <section className="auth-card">
<div className="eyebrow">Secure reset</div> <div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
<h1>Choose a new mailbox password</h1> <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 === "loading" ? <p className="auth-copy">Checking your reset link…</p> : null}
{status === "invalid" ? ( {status === "invalid" ? (
<> <>
<p className="auth-copy"> <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> </p>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
</> </>
@@ -89,14 +93,18 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
{status === "done" ? ( {status === "done" ? (
<p className="notice success"> <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> </p>
) : null} ) : null}
{status === "ready" ? ( {status === "ready" ? (
<> <>
<p className="auth-copy"> <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> </p>
<form className="stack" onSubmit={handleSubmit}> <form className="stack" onSubmit={handleSubmit}>
+4 -4
View File
@@ -22,10 +22,9 @@ export type CreateUserRequest = {
}; };
export type CreateUserResponse = { export type CreateUserResponse = {
user: MailUser; email: string;
resetLinkSent: boolean; setupLink: string;
resetLinkExpiresAt: string | null; expiresAt: string;
warning: string | null;
}; };
export type LoginRequest = { export type LoginRequest = {
@@ -42,6 +41,7 @@ export type PasswordResetInspectRequest = {
}; };
export type PasswordResetInspectResponse = { export type PasswordResetInspectResponse = {
mode: "invite" | "reset";
emailHint: string; emailHint: string;
expiresAt: string; expiresAt: string;
}; };