Compare commits

..
10 Commits
Author SHA1 Message Date
Anaïs Saraiva 487aaad873 fixes 2026-10-02 10:57:38 +02:00
Anaïs Saraiva 95cfbff731 Update runner.ts 2026-07-22 10:03:26 +02:00
Anaïs Saraiva 7770243b14 remove 2026-07-05 22:28:49 +02:00
Debian 1aa105ebb1 . 2026-03-30 16:58:37 +00:00
Debian 0f575efb76 . 2026-03-30 16:47:44 +00:00
Debian 31df5a4aa0 . 2026-03-30 16:36:59 +00:00
Debian e4506815ef . 2026-03-30 14:42:49 +00:00
Debian 44ccbd92bd . 2026-03-30 14:35:32 +00:00
Debian 37159aa2ed . 2026-03-30 14:34:15 +00:00
Debian 59310b0b9b . 2026-03-30 14:32:24 +00:00
25 changed files with 1734 additions and 780 deletions
View File
+2 -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
APP_ADMIN_EMAIL=panel-admin@local MAILBOX_INVITE_TTL_HOURS=48
APP_ADMIN_EMAIL=admin@local
APP_ADMIN_ACCESS_KEY=change-this-access-key APP_ADMIN_ACCESS_KEY=change-this-access-key
+3
View File
@@ -8,3 +8,6 @@ data/app.db
data/app.db-shm data/app.db-shm
data/app.db-wal data/app.db-wal
coverage coverage
bun.lock
not.md
.codex
+18 -17
View File
@@ -16,7 +16,7 @@ Why this backend:
- Bun keeps the runtime, package manager, and SQLite support simple. - Bun keeps the runtime, package manager, and SQLite support simple.
- ElysiaJS is a good fit for a small internal API without extra framework weight. - ElysiaJS is a good fit for a small internal API without extra framework weight.
- SQLite is enough for admin sessions and password reset tokens, and keeps deployment straightforward. - SQLite is enough for admin sessions and a small amount of panel metadata, and keeps deployment straightforward.
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers. - Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
### Frontend ### Frontend
@@ -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 password reset flow. Contains the mailbox user operations, pending mailbox invites, and the one-time link flow for password changes.
- `apps/api/src/mail/service.ts`
Sends reset emails 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.
@@ -80,7 +78,6 @@ The backend is intentionally flat:
- `POST /api/auth/logout` - `POST /api/auth/logout`
- `GET /api/users` - `GET /api/users`
- `POST /api/users` - `POST /api/users`
- `PATCH /api/users/:email/password`
- `DELETE /api/users/:email` - `DELETE /api/users/:email`
- `POST /api/users/:email/password-reset` - `POST /api/users/:email/password-reset`
- `POST /api/password-resets/inspect` - `POST /api/password-resets/inspect`
@@ -99,24 +96,24 @@ The dashboard includes:
- mailbox listing - mailbox listing
- simple search - simple search
- create user modal - create user modal
- edit password modal - 48-hour setup link generation for new mailboxes
- temporary link generation for password changes on existing mailboxes
- delete action - delete action
- send reset 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 ## 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 triggers a reset 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 user receives an email with the one-time reset link. 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
@@ -140,12 +137,15 @@ Security choices here:
- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables. - Admin authentication uses an Argon2id-hashed bootstrap account from environment variables.
- Panel login uses a single access key instead of an email/password pair. - Panel login uses a single access key instead of an email/password pair.
- 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.
- 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.
- Allowed origins are restricted by `APP_ORIGINS`. - Allowed origins are restricted by `APP_ORIGINS`.
- Reset emails are sent from the backend only. No secrets are exposed to the frontend. - Setup emails for new accounts are sent from the backend only. No secrets are exposed to the frontend.
- Docker Mailserver access is treated as privileged infrastructure access. - Docker Mailserver access is treated as privileged infrastructure access.
## 8. Run locally ## 8. Run locally
@@ -165,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
+5 -1
View File
@@ -95,7 +95,11 @@ export const runDocker = async (
throw new HttpError(504, "Docker Mailserver command timed out."); throw new HttpError(504, "Docker Mailserver command timed out.");
} }
throw new HttpError(502, "Docker Mailserver is unreachable."); if (error instanceof Error && "code" in error && error.code === "ENOENT") {
throw new HttpError(502, `Docker binary '${config.dockerBinary}' was not found. Set DOCKER_BINARY to the absolute docker path on NixOS.`);
}
throw new HttpError(502, error instanceof Error ? error.message : "Docker Mailserver is unreachable.");
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
+20 -8
View File
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
import { cleanupDatabase } from "./lib/db"; import { cleanupDatabase } from "./lib/db";
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http"; import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config"; import { config } from "./lib/config";
import { HttpError } from "./lib/errors";
import { serveFrontendBuild } from "./lib/frontend"; import { serveFrontendBuild } from "./lib/frontend";
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
import { import {
createUserSchema, createUserSchema,
loginSchema, loginSchema,
@@ -65,15 +67,25 @@ app.get(
app.post( app.post(
"/api/auth/login", "/api/auth/login",
route(async ({ request }) => { route(async ({ request }) => {
const body = await parseJson(request, loginSchema); enforceAggressiveLoginRateLimit(request);
const result = await loginAdmin(body.accessKey);
return jsonResponse( try {
request, const body = await parseJson(request, loginSchema);
200, const result = await loginAdmin(body.accessKey);
{ session: result.session }, clearAccessKeyLoginRateLimit(request);
{ "set-cookie": result.cookie }
); return jsonResponse(
request,
200,
{ session: result.session },
{ "set-cookie": result.cookie }
);
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
recordFailedAccessKeyLogin(request);
}
throw error;
}
}) })
); );
+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);
}; };
+3 -1
View File
@@ -1,8 +1,10 @@
export class HttpError extends Error { export class HttpError extends Error {
readonly status: number; readonly status: number;
readonly headers?: HeadersInit;
constructor(status: number, message: string) { constructor(status: number, message: string, headers?: HeadersInit) {
super(message); super(message);
this.status = status; this.status = status;
this.headers = headers;
} }
} }
+2 -2
View File
@@ -43,6 +43,7 @@ export const assertAllowedOrigin = (request: Request): void => {
return; return;
} }
console.log(config.appOrigins, origin)
if (!config.appOrigins.includes(origin.toLowerCase())) { if (!config.appOrigins.includes(origin.toLowerCase())) {
throw new HttpError(403, "Origin is not allowed."); throw new HttpError(403, "Origin is not allowed.");
} }
@@ -96,7 +97,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
error: { error: {
message: error.message message: error.message
} }
}); }, error.headers);
} }
if (error instanceof ZodError) { if (error instanceof ZodError) {
@@ -115,4 +116,3 @@ export const errorResponse = (request: Request, error: unknown): Response => {
} }
}); });
}; };
+126
View File
@@ -1,4 +1,102 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { HttpError } from "./errors";
type LoginRateLimitState = {
blockedUntil: number;
lastSeenAt: number;
recentAttempts: number[];
failedAttempts: number[];
};
const loginRateLimitStates = new Map<string, LoginRateLimitState>();
const LOGIN_BURST_WINDOW_MS = 15 * 60_000;
const LOGIN_BURST_LIMIT = 1;
const LOGIN_FAILURE_TRACK_MS = 365 * 24 * 60 * 60_000;
const LOGIN_STATE_TTL_MS = 400 * 24 * 60 * 60_000;
const getRetryAfterSeconds = (blockedUntil: number): number =>
Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
const getClientIp = (request: Request): string => {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
const first = forwardedFor.split(",")[0]?.trim();
if (first) {
return first;
}
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) {
return realIp;
}
return "unknown";
};
const getLoginRateLimitKey = (request: Request): string =>
getClientIp(request);
const pruneLoginRateLimitState = (state: LoginRateLimitState, now: number): void => {
state.recentAttempts = state.recentAttempts.filter((timestamp) => now - timestamp <= LOGIN_BURST_WINDOW_MS);
state.failedAttempts = state.failedAttempts.filter((timestamp) => now - timestamp <= LOGIN_FAILURE_TRACK_MS);
};
const getLoginRateLimitState = (key: string, now: number): LoginRateLimitState => {
for (const [stateKey, state] of loginRateLimitStates.entries()) {
if (now - state.lastSeenAt > LOGIN_STATE_TTL_MS && now >= state.blockedUntil) {
loginRateLimitStates.delete(stateKey);
}
}
const existing = loginRateLimitStates.get(key);
if (existing) {
existing.lastSeenAt = now;
pruneLoginRateLimitState(existing, now);
return existing;
}
const nextState: LoginRateLimitState = {
blockedUntil: 0,
lastSeenAt: now,
recentAttempts: [],
failedAttempts: []
};
loginRateLimitStates.set(key, nextState);
return nextState;
};
const createRateLimitError = (blockedUntil: number): HttpError =>
new HttpError(
429,
"Too many access key login attempts. Login is locked for this client.",
{
"Retry-After": String(getRetryAfterSeconds(blockedUntil))
}
);
const applyLoginFailurePenalty = (state: LoginRateLimitState, now: number): void => {
const failureCount = state.failedAttempts.length;
let blockMs = 0;
if (failureCount >= 5) {
blockMs = 365 * 24 * 60 * 60_000;
} else if (failureCount >= 4) {
blockMs = 180 * 24 * 60 * 60_000;
} else if (failureCount >= 3) {
blockMs = 30 * 24 * 60 * 60_000;
} else if (failureCount >= 2) {
blockMs = 7 * 24 * 60 * 60_000;
} else if (failureCount >= 1) {
blockMs = 24 * 60 * 60_000;
}
if (blockMs > 0) {
state.blockedUntil = Math.max(state.blockedUntil, now + blockMs);
}
};
export const hashToken = (value: string): string => export const hashToken = (value: string): string =>
createHash("sha256").update(value).digest("hex"); createHash("sha256").update(value).digest("hex");
@@ -54,3 +152,31 @@ export const readCookie = (request: Request, name: string): string | null => {
export const sanitizeHeaderValue = (value: string): string => export const sanitizeHeaderValue = (value: string): string =>
value.replace(/[\r\n]+/g, " ").trim(); value.replace(/[\r\n]+/g, " ").trim();
export const enforceAggressiveLoginRateLimit = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
if (now < state.blockedUntil) {
throw createRateLimitError(state.blockedUntil);
}
state.recentAttempts.push(now);
if (state.recentAttempts.length > LOGIN_BURST_LIMIT) {
state.blockedUntil = now + 24 * 60 * 60_000;
throw createRateLimitError(state.blockedUntil);
}
};
export const recordFailedAccessKeyLogin = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
state.failedAttempts.push(now);
applyLoginFailurePenalty(state, now);
};
export const clearAccessKeyLoginRateLimit = (request: Request): void => {
loginRateLimitStates.delete(getLoginRateLimitKey(request));
};
+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) {
+5 -3
View File
@@ -2,11 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Mail Admin</title> <meta name="theme-color" content="#0b0d10" />
<meta name="color-scheme" content="dark" />
<title>Internal iHorizon Mail System</title>
<meta <meta
name="description" name="description"
content="Admin panel for Docker Mailserver user management." content="www.ihorizon.org"
/> />
</head> </head>
<body> <body>
+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);
} }
+49 -15
View File
@@ -1,4 +1,7 @@
import { useEffect, useState } from "react";
type ResetLinkModalProps = { type ResetLinkModalProps = {
variant?: "invite" | "reset";
email: string; email: string;
resetLink: string; resetLink: string;
expiresAt: string; expiresAt: string;
@@ -6,51 +9,82 @@ type ResetLinkModalProps = {
}; };
export default function ResetLinkModal({ export default function ResetLinkModal({
variant = "reset",
email, email,
resetLink, resetLink,
expiresAt, expiresAt,
onClose onClose
}: ResetLinkModalProps) { }: ResetLinkModalProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleCopy = async () => { const handleCopy = async () => {
try { try {
await navigator.clipboard.writeText(resetLink); await navigator.clipboard.writeText(resetLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch { } catch {
// The field stays visible even if clipboard access is unavailable. // Fallback: select the field so the user can copy manually.
setCopied(false);
} }
}; };
const isInvite = variant === "invite";
return ( return (
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal-card" role="dialog" aria-modal="true"> <div
className="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="reset-link-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2>Temporary reset link</h2> <h2 id="reset-link-title">{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="modal-close" onClick={onClose} aria-label="Close">
Close ✕
</button> </button>
</div> </div>
<div className="stack"> <div className="stack">
<label className="field"> <div className="link-field">
<span>One-time link</span> <label className="field">
<input type="text" readOnly value={resetLink} /> <span>{isInvite ? "Setup link" : "One-time link"}</span>
</label> <div className="link-row">
<input type="text" readOnly value={resetLink} onFocus={(e) => e.target.select()} />
<button type="button" className="ghost-button" onClick={handleCopy}>
{copied ? "Copied ✓" : "Copy"}
</button>
</div>
</label>
<span className="copy-feedback" aria-live="polite">{copied ? "Link copied to clipboard." : ""}</span>
</div>
<p className="modal-copy"> <p className="modal-copy">
This link expires at {expiresAt} and becomes invalid after first use. This link expires at {expiresAt} and becomes invalid after first use.
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose}> <button type="button" className="primary-button" onClick={onClose}>
Done Done
</button> </button>
<button type="button" className="primary-button" onClick={handleCopy}>
Copy link
</button>
</div> </div>
</div> </div>
</div> </div>
+25 -6
View File
@@ -19,6 +19,16 @@ export default function UserFormModal({
setError(null); setError(null);
}, []); }, []);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) {
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [busy, onClose]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
@@ -40,17 +50,23 @@ export default function UserFormModal({
}; };
return ( return (
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation" onClick={() => !busy && onClose()}>
<div className="modal-card" role="dialog" aria-modal="true"> <div
className="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="create-user-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2>Create user</h2> <h2 id="create-user-title">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="modal-close" onClick={onClose} disabled={busy} aria-label="Close">
Close ✕
</button> </button>
</div> </div>
@@ -62,6 +78,9 @@ export default function UserFormModal({
value={nextEmail} value={nextEmail}
onChange={(event) => setNextEmail(event.target.value)} onChange={(event) => setNextEmail(event.target.value)}
placeholder="[email protected]" placeholder="[email protected]"
autoComplete="email"
inputMode="email"
enterKeyHint="done"
autoFocus autoFocus
disabled={busy} disabled={busy}
/> />
+262 -103
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared"; import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
import ResetLinkModal from "../components/ResetLinkModal"; import ResetLinkModal from "../components/ResetLinkModal";
import UserFormModal from "../components/UserFormModal"; import UserFormModal from "../components/UserFormModal";
@@ -16,6 +16,11 @@ type DashboardPageProps = {
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
}; };
function initialOf(email: string): string {
const trimmed = email.trim();
return trimmed ? trimmed.charAt(0).toUpperCase() : "?";
}
export default function DashboardPage({ export default function DashboardPage({
adminEmail, adminEmail,
users, users,
@@ -33,6 +38,9 @@ export default function DashboardPage({
const [modalBusy, setModalBusy] = useState(false); const [modalBusy, setModalBusy] = useState(false);
const [inlineMessage, setInlineMessage] = useState<string | null>(null); const [inlineMessage, setInlineMessage] = useState<string | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [linkBusyFor, setLinkBusyFor] = useState<string | null>(null);
const [resetLinkData, setResetLinkData] = useState<{ const [resetLinkData, setResetLinkData] = useState<{
email: string; email: string;
resetLink: string; resetLink: string;
@@ -40,9 +48,13 @@ export default function DashboardPage({
} | null>(null); } | null>(null);
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = normalizedQuery const filteredUsers = useMemo(
? users.filter((user) => user.email.includes(normalizedQuery)) () =>
: users; normalizedQuery
? users.filter((user) => user.email.toLowerCase().includes(normalizedQuery))
: users,
[users, normalizedQuery]
);
const handleCreate = async ({ email }: { email?: string }) => { const handleCreate = async ({ email }: { email?: string }) => {
if (!email) { if (!email) {
@@ -55,50 +67,136 @@ 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);
} }
}; };
const handleGenerateLink = async (email: string) => {
setInlineError(null);
setLinkBusyFor(email);
try {
const result = await onTriggerReset(email);
setResetLinkData({
email,
resetLink: result.resetLink,
expiresAt: result.expiresAt
});
setInlineMessage(`Temporary reset link generated for ${email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
} finally {
setLinkBusyFor(null);
}
};
const handleConfirmDelete = async () => {
if (!pendingDelete) {
return;
}
setDeleteBusy(true);
setInlineError(null);
try {
await onDeleteUser(pendingDelete);
setInlineMessage(`Deleted ${pendingDelete}.`);
setPendingDelete(null);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
} finally {
setDeleteBusy(false);
}
};
return ( return (
<main className="page-shell"> <main className="page-shell">
<header className="page-header"> <header className="page-header">
<div> <div className="brand-row">
<div className="eyebrow">Mail Admin Panel</div> <div className="logo-mark" aria-hidden="true">@</div>
<h1>Mailbox users</h1> <div className="brand-text">
<p className="page-copy"> <div className="eyebrow">Admin Panel</div>
Create and manage Docker Mailserver accounts without touching the container by hand. <h1>Mailbox users</h1>
</p> <p className="page-copy">Create and manage Docker Mailserver accounts.</p>
</div>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<div className="admin-pill">{adminEmail}</div> <div className="admin-pill" title={adminEmail}>
<button className="ghost-button" onClick={onRefresh} disabled={busy}> <span className="admin-avatar" aria-hidden="true">{initialOf(adminEmail)}</span>
Refresh <span className="admin-email">{adminEmail}</span>
</button> </div>
<button className="ghost-button" onClick={onLogout} disabled={busy}> <div className="header-icon-group">
Sign out <button
</button> type="button"
className="icon-button"
onClick={onRefresh}
disabled={busy}
aria-label="Refresh users"
title="Refresh"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
</button>
<button
type="button"
className="icon-button"
onClick={onLogout}
disabled={busy}
aria-label="Sign out"
title="Sign out"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
</div> </div>
</header> </header>
<section className="toolbar-card"> <section className="toolbar-card" aria-label="Search and create">
<label className="search-field"> <label className="search-field">
<span>Search</span> <span>Search users</span>
<input <div className="search-wrap">
type="search" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
value={query} <circle cx="11" cy="11" r="7" />
onChange={(event) => setQuery(event.target.value)} <path d="m21 21-4.3-4.3" />
placeholder="Search by email" </svg>
/> <input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by email"
autoComplete="off"
enterKeyHint="search"
/>
{query ? (
<button
type="button"
className="search-clear"
onClick={() => setQuery("")}
aria-label="Clear search"
>
×
</button>
) : null}
</div>
</label> </label>
<button className="primary-button" onClick={() => setCreateOpen(true)} disabled={busy}> <div className="toolbar-meta">
Create user <span className="count-badge">{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</span>
</div>
<button className="primary-button toolbar-create" onClick={() => setCreateOpen(true)} disabled={busy}>
+ Create user
</button> </button>
</section> </section>
@@ -107,88 +205,116 @@ export default function DashboardPage({
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null} {inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
{inlineError ? <p className="notice error">{inlineError}</p> : null} {inlineError ? <p className="notice error">{inlineError}</p> : null}
<section className="table-card"> <section className="table-card" aria-label="Users">
<div className="table-header"> <div className="table-header">
<div> <div>
<h2>Users</h2> <h2>Users</h2>
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</p> <p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}{normalizedQuery ? ` for “${query.trim()}”` : ""}</p>
</div> </div>
</div> </div>
<div className="table-wrap"> {filteredUsers.length === 0 ? (
<table> <div className="empty-state">
<thead> <div className="empty-illust" aria-hidden="true">✉️</div>
<tr> <h2>{normalizedQuery ? "No match" : "No users yet"}</h2>
<th>Email</th> <p>{normalizedQuery ? "Try another email address, or create this mailbox directly." : "Create your first mailbox with the button below."}</p>
<th>Local part</th> {normalizedQuery ? (
<th>Domain</th> <button type="button" className="ghost-button" onClick={() => setQuery("")}>
<th>Actions</th> Clear search
</tr> </button>
</thead> ) : null}
<tbody> </div>
{filteredUsers.length === 0 ? ( ) : (
<tr> <>
<td colSpan={4} className="empty-row"> <ul className="user-cards">
No users found. {filteredUsers.map((user) => (
</td> <li key={user.email} className="user-card">
</tr> <div className="user-card-top">
) : ( <span className="user-avatar" aria-hidden="true">{initialOf(user.email)}</span>
filteredUsers.map((user) => ( <div className="user-identity">
<tr key={user.email}> <div className="user-email">{user.email}</div>
<td>{user.email}</td> <div className="user-sub">
<td>{user.localPart}</td> <span className="domain-chip">@{user.domain}</span>
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button
className="ghost-button"
onClick={async () => {
setInlineError(null);
try {
const result = await onTriggerReset(user.email);
setResetLinkData({
email: user.email,
resetLink: result.resetLink,
expiresAt: result.expiresAt
});
setInlineMessage(`Temporary reset link generated for ${user.email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
}
}}
disabled={busy}
>
Generate link
</button>
<button
className="danger-button"
onClick={async () => {
const confirmed = window.confirm(`Delete ${user.email}?`);
if (!confirmed) {
return;
}
setInlineError(null);
try {
await onDeleteUser(user.email);
setInlineMessage(`Deleted ${user.email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
}
}}
disabled={busy}
>
Delete
</button>
</div> </div>
</td> </div>
</div>
<div className="user-card-actions">
<button
type="button"
className="ghost-button"
onClick={() => handleGenerateLink(user.email)}
disabled={busy || linkBusyFor !== null}
>
{linkBusyFor === user.email ? "…" : "Reset link"}
</button>
<button
type="button"
className="danger-button"
onClick={() => setPendingDelete(user.email)}
disabled={busy}
>
Delete
</button>
</div>
</li>
))}
</ul>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Local part</th>
<th>Domain</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr> </tr>
)) </thead>
)} <tbody>
</tbody> {filteredUsers.map((user) => (
</table> <tr key={user.email}>
</div> <td>{user.email}</td>
<td>{user.localPart}</td>
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button
type="button"
className="ghost-button"
onClick={() => handleGenerateLink(user.email)}
disabled={busy || linkBusyFor !== null}
>
{linkBusyFor === user.email ? "Generating…" : "Generate link"}
</button>
<button
type="button"
className="danger-button"
onClick={() => setPendingDelete(user.email)}
disabled={busy}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section> </section>
<button
type="button"
className="fab"
onClick={() => setCreateOpen(true)}
disabled={busy}
aria-label="Create user"
>
<span className="fab-plus" aria-hidden="true">+</span> New user
</button>
{createOpen ? ( {createOpen ? (
<UserFormModal <UserFormModal
busy={modalBusy} busy={modalBusy}
@@ -199,12 +325,45 @@ 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}
onClose={() => setResetLinkData(null)} onClose={() => setResetLinkData(null)}
/> />
) : null} ) : null}
{pendingDelete ? (
<div className="modal-backdrop" role="presentation" onClick={() => !deleteBusy && setPendingDelete(null)}>
<div
className="modal-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div>
<h2 id="delete-title">Delete mailbox?</h2>
<p className="danger-zone-text">
This will permanently delete <strong>{pendingDelete}</strong>. This cannot be undone.
</p>
</div>
<button type="button" className="modal-close" onClick={() => setPendingDelete(null)} disabled={deleteBusy} aria-label="Close">
✕
</button>
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setPendingDelete(null)} disabled={deleteBusy}>
Cancel
</button>
<button type="button" className="danger-button" onClick={handleConfirmDelete} disabled={deleteBusy}>
{deleteBusy ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
) : null}
</main> </main>
); );
} }
+34 -13
View File
@@ -8,6 +8,7 @@ type LoginPageProps = {
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) { export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
const [accessKey, setAccessKey] = useState(""); const [accessKey, setAccessKey] = useState("");
const [showKey, setShowKey] = useState(false);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -18,9 +19,15 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
return ( return (
<main className="auth-shell"> <main className="auth-shell">
<section className="auth-card"> <section className="auth-card" aria-labelledby="login-title">
<div className="eyebrow">Docker Mailserver</div> <div className="auth-top">
<h1>Mail user management</h1> <div className="logo-mark" aria-hidden="true">@</div>
<div>
<div className="eyebrow">Mailserver</div>
<div style={{ fontSize: "0.85rem", color: "var(--text-muted)" }}>Internal admin access</div>
</div>
</div>
<h1 id="login-title">User management</h1>
<p className="auth-copy"> <p className="auth-copy">
Sign in with the access key configured for this panel. Sign in with the access key configured for this panel.
</p> </p>
@@ -28,22 +35,36 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
<form className="stack" onSubmit={handleSubmit}> <form className="stack" onSubmit={handleSubmit}>
<label className="field"> <label className="field">
<span>Access key</span> <span>Access key</span>
<input <div className="password-wrap">
type="password" <input
value={accessKey} type={showKey ? "text" : "password"}
onChange={(event) => setAccessKey(event.target.value)} value={accessKey}
autoComplete="current-password" onChange={(event) => setAccessKey(event.target.value)}
autoFocus autoComplete="current-password"
disabled={busy} enterKeyHint="go"
/> autoFocus
disabled={busy}
placeholder="••••••••"
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "Hide access key" : "Show access key"}
>
{showKey ? "Hide" : "Show"}
</button>
</div>
</label> </label>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error" role="alert">{error}</p> : null}
<button type="submit" className="primary-button" disabled={busy}> <button type="submit" className="primary-button" disabled={busy || !accessKey.trim()}>
{busy ? "Signing in..." : "Sign in"} {busy ? "Signing in..." : "Sign in"}
</button> </button>
</form> </form>
<div className="auth-foot">Protected panel — keys are never shown on screen.</div>
</section> </section>
</main> </main>
); );
+43 -17
View File
@@ -7,10 +7,12 @@ 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("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [showPasswords, setShowPasswords] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -23,6 +25,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");
@@ -72,56 +75,79 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
return ( return (
<main className="auth-shell"> <main className="auth-shell">
<section className="auth-card"> <section className="auth-card" aria-labelledby="reset-title">
<div className="eyebrow">Secure reset</div> <div className="auth-top">
<h1>Choose a new mailbox password</h1> <div className="logo-mark" aria-hidden="true">@</div>
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
</div>
<h1 id="reset-title">{mode === "invite" ? "Create your password" : "Choose a new 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" role="alert">{error}</p> : null}
</> </>
) : null} ) : null}
{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}>
<label className="field"> <label className="field">
<span>New password</span> <span>New password (min. 12 characters)</span>
<input <div className="password-wrap">
type="password" <input
value={password} type={showPasswords ? "text" : "password"}
onChange={(event) => setPassword(event.target.value)} value={password}
autoFocus onChange={(event) => setPassword(event.target.value)}
disabled={busy} autoComplete="new-password"
/> enterKeyHint="next"
autoFocus
disabled={busy}
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowPasswords((v) => !v)}
aria-label={showPasswords ? "Hide passwords" : "Show passwords"}
>
{showPasswords ? "Hide" : "Show"}
</button>
</div>
</label> </label>
<label className="field"> <label className="field">
<span>Confirm password</span> <span>Confirm password</span>
<input <input
type="password" type={showPasswords ? "text" : "password"}
value={confirmPassword} value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)} onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
enterKeyHint="done"
disabled={busy} disabled={busy}
/> />
</label> </label>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error" role="alert">{error}</p> : null}
<button type="submit" className="primary-button" disabled={busy}> <button type="submit" className="primary-button" disabled={busy}>
{busy ? "Updating..." : "Set password"} {busy ? "Updating..." : "Set password"}
File diff suppressed because it is too large Load Diff
-328
View File
@@ -1,328 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "internal-mail-server",
},
"apps/api": {
"name": "@internal-mail/api",
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.10",
"zod": "^3.25.0",
},
"devDependencies": {
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
},
},
"apps/web": {
"name": "@internal-mail/web",
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"react": "^19.1.1",
"react-dom": "^19.1.1",
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^5.0.0",
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
"vite": "^7.0.0",
},
},
"packages/shared": {
"name": "@internal-mail/shared",
"version": "0.0.1",
"devDependencies": {
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@internal-mail/api": ["@internal-mail/api@workspace:apps/api"],
"@internal-mail/shared": ["@internal-mail/shared@workspace:packages/shared"],
"@internal-mail/web": ["@internal-mail/web@workspace:apps/web"],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@tokenizer/inflate": ["@tokenizer/[email protected]", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/[email protected]", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="],
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-type": ["[email protected]", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["[email protected]", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["[email protected]", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strtok3": ["[email protected]", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"token-types": ["[email protected]", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["[email protected]", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}
+22
View File
@@ -0,0 +1,22 @@
const path = require("node:path");
module.exports = {
apps: [
{
name: "internal-mailserver",
cwd: path.join(__dirname, "apps/api"),
script: "bun",
args: "--env-file ../../.env dist/index.js",
interpreter: "none",
exec_mode: "fork",
instances: 1,
autorestart: true,
watch: false,
time: true,
max_memory_restart: "512M",
env: {
NODE_ENV: "production"
}
}
]
};
+9 -8
View File
@@ -2,17 +2,18 @@ server {
listen 80; listen 80;
server_name _; server_name _;
location / { client_max_body_size 2m;
proxy_pass http://frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ { location / {
proxy_pass http://api:3001; proxy_pass http://127.0.0.1:16731;
proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
proxy_read_timeout 60s;
proxy_send_timeout 60s;
} }
} }
-13
View File
@@ -1,13 +0,0 @@
Fait moi un prompt pour mon AI qui doit faire sa: (en anglais btw):
Code moi un backend-frontend (Frontend: choix du framework): Backend ElysiaJS (avec Bun)
Le back utilise docker en CLI pour communiquer avec le docker mail-server
Le projet est un front pour gérer les utilisateurs emails d'un serveur dockermail-server.
Créer, ajouter, modifier, supprimer, lister des utilisateurs
Modifier le mot de passe avec un one-time link (à envoyer à l'utilisateur pour politique sécurité)
Le projet doit être KISS (Keep It Simple Stupid). Simple, pas de complexité nécessaire, le front-end doit être design sobre
La codebase doit être simple, propre. Pour pouvoir y ajoutez des futurs fonctionalité
+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;
}; };