diff --git a/.env.example b/.env.example index 7cd3de8..01df051 100644 --- a/.env.example +++ b/.env.example @@ -2,22 +2,15 @@ NODE_ENV=development APP_HOST=0.0.0.0 APP_PORT=3001 APP_ORIGINS=http://localhost:5173 -APP_EXTERNAL_BASE_URL=https://mail.example.com +APP_EXTERNAL_BASE_URL=http://localhost:5173 +APP_DATA_PATH=../../data/app.db MAILSERVER_CONTAINER=mailserver +DOCKER_BINARY=docker MAILSERVER_TIMEOUT_MS=10000 HOSTED_DOMAINS=example.com -FORWARDING_ALLOWED_DOMAINS=example.com -SESSION_TTL_MINUTES=15 -GLOBAL_RATE_LIMIT=240 -ROUTE_RATE_LIMIT=90 -RATE_LIMIT_WINDOW_SECONDS=60 -LOGIN_RATE_LIMIT=5 -LOGIN_WINDOW_SECONDS=600 -MAX_MESSAGES=25 -AVATAR_MAX_BYTES=2097152 -TRUST_PROXY=false +SESSION_COOKIE_NAME=mail_admin_session +SESSION_TTL_MINUTES=480 +RESET_TOKEN_TTL_MINUTES=60 APP_ADMIN_EMAIL=admin@example.com APP_ADMIN_PASSWORD=change-me-now -# 32-byte base64 key used to encrypt TOTP secrets at rest. -TOTP_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY - +APP_MAIL_FROM=no-reply@example.com diff --git a/.gitignore b/.gitignore index de5adef..2bccee6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,6 @@ build .env .env.local data/app.db -data/avatars/* -!data/avatars/.gitkeep +data/app.db-shm +data/app.db-wal coverage - -apps/api/data/app.db -apps/api/data/app.db-shm -apps/api/data/app.db-wal diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..54f6c0d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "discord.enabled": false +} \ No newline at end of file diff --git a/README.md b/README.md index c382aba..a98e143 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,191 @@ -# Secure Webmail for docker-mailserver +# Docker Mailserver User Management Panel -A full-stack webmail application built with Bun, ElysiaJS, React, and Vite. The app is designed for public-internet exposure and treats docker-mailserver as an untrusted integration boundary that must be wrapped carefully. +A small full-stack admin tool for managing Docker Mailserver accounts with Bun, ElysiaJS, and React. -## Stack +## 1. Proposed stack -- Backend: Bun + ElysiaJS -- Frontend: React + Vite running on Bun -- Storage: Bun SQLite for app metadata, sessions, user preferences, and encrypted TOTP secrets -- Mail integration: Docker CLI with fixed argument arrays only +### Backend -## Security-first design +- Bun +- ElysiaJS +- Bun SQLite +- Docker CLI through fixed argument arrays +- Zod for small, explicit validation -### Critical risks and mitigations +Why this backend: -| Risk | Mitigation | -| --- | --- | -| Command injection through Docker CLI | All Docker calls use `Bun.spawn()` with explicit argument arrays. No shell string concatenation, no `sh -c`, no raw user input in command strings. | -| Session theft or token leakage | Server-side opaque sessions in `httpOnly`, `SameSite=Strict` cookies. The frontend never stores session identifiers in `localStorage`. | -| CSRF | Every state-changing route requires an in-memory CSRF token in `X-CSRF-Token` plus the authenticated cookie. | -| XSS from HTML email | HTML email is sanitized on the backend, dangerous tags are removed, remote images are not allowed, and the frontend renders sanitized HTML only in a sandboxed iframe. | -| Brute-force login | Dedicated login rate limiter plus a global limiter. | -| Unsafe file uploads | Avatar uploads are size-limited, magic-byte checked, renamed with random tokens, stored outside the public root, and served through an authenticated endpoint. | -| Forwarding abuse | Forwarding targets are strictly validated, self-forwarding is blocked, and allowed target domains are policy-controlled. | -| Secret exposure | No passwords or TOTP secrets are logged. TOTP secrets are encrypted at rest with AES-GCM via `TOTP_ENCRYPTION_KEY`. | -| Information leakage | API errors return generic messages for internal failures and use explicit HTTP status codes for validation and auth issues. | +- Bun keeps the runtime, package manager, and SQLite support simple. +- 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. +- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers. -### Why server-side sessions instead of JWTs +### Frontend -This project uses secure server-side sessions instead of bearer tokens or long-lived JWTs. That keeps revocation simple, reduces token exposure risk in the browser, and avoids putting sensitive auth state in JavaScript storage. +- React +- Vite +- Plain CSS -## Features +Why React: -- Mailbox login against docker-mailserver via `doveadm auth test` -- Optional seeded local admin account with Argon2id-hashed password -- Inbox listing and message preview from Maildir inside the mail container -- Plain-text compose flow using `sendmail` inside the container -- Safe HTML email preview -- Profile settings, avatar upload, and forwarding management -- Optional TOTP two-factor authentication -- Security headers and restricted CORS +- It is easy for most full-stack teams to read and maintain. +- This panel only needs basic local state and forms, so React works well without routers or global state libraries. +- The UI stays minimal while still being easy to grow later. -## Project layout +## 2. Folder structure ```text apps/ - api/ Elysia backend - web/ React/Vite frontend + api/ + src/ + auth/ + docker/ + lib/ + mail/ + users/ + web/ + src/ + api/ + components/ + pages/ + styles/ packages/ - shared/ Shared TypeScript types -infra/ - nginx/ Reverse proxy example with security headers + shared/ data/ - avatars/ Stored outside the public web root + app.db +infra/ + nginx/ ``` -## Local development +## 3. Backend implementation -### 1. Configure environment +The backend is intentionally flat: -Copy `.env.example` to `.env` and update the values: +- `apps/api/src/index.ts` + Defines the HTTP routes and keeps request flow obvious. +- `apps/api/src/auth/service.ts` + Handles admin login, bootstrap admin seeding, session cookies, and session lookup. +- `apps/api/src/docker/` + Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation. +- `apps/api/src/users/service.ts` + Contains the mailbox user operations and password reset flow. +- `apps/api/src/mail/service.ts` + Sends reset emails through `sendmail` inside the Docker Mailserver container. +- `apps/api/src/lib/` + Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities. -- `HOSTED_DOMAINS`: domains served by docker-mailserver -- `MAILSERVER_CONTAINER`: container name to run `docker exec` against -- `APP_ORIGINS`: allowed frontend origins -- `TOTP_ENCRYPTION_KEY`: base64-encoded 32-byte key -- `APP_ADMIN_EMAIL` / `APP_ADMIN_PASSWORD`: optional local admin bootstrap account +### API design -Generate a 32-byte base64 key: +- `GET /api/auth/session` +- `POST /api/auth/login` +- `POST /api/auth/logout` +- `GET /api/users` +- `POST /api/users` +- `PATCH /api/users/:email/password` +- `DELETE /api/users/:email` +- `POST /api/users/:email/password-reset` +- `POST /api/password-resets/inspect` +- `POST /api/password-resets/consume` + +## 4. Frontend implementation + +The frontend is a small React app with three views: + +- Login page +- Dashboard page +- Public reset-password page + +The dashboard includes: + +- mailbox listing +- simple search +- create user modal +- edit password modal +- delete action +- send reset action + +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. + +## 5. Password reset flow + +The reset flow is built to stay secure without becoming heavy: + +1. An admin triggers a reset for an existing mailbox. +2. The backend generates a random opaque token. +3. Only the SHA-256 hash of that token is stored in SQLite. +4. The user receives an email with the one-time reset link. +5. The reset page validates the token before showing the password form. +6. On submit, the token is claimed for one use only. +7. The backend updates the mailbox password through Docker Mailserver. +8. Used or expired tokens are rejected. + +## 6. Docker CLI integration strategy + +The app talks to Docker Mailserver through `docker exec` with explicit argument arrays: + +- `setup email list` +- `setup email add ` +- `setup email update ` +- `setup email del ` +- `sendmail -t -i` + +Security choices here: + +- No `sh -c` +- No user input inserted into shell strings +- Input is validated before command execution +- Docker commands have a timeout +- Errors are returned as API failures instead of leaking raw implementation detail to the browser + +## 7. Security notes + +- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables. +- 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. +- Admin-only routes require a valid session cookie. +- Validation is applied to logins, emails, passwords, and reset payloads. +- Allowed origins are restricted by `APP_ORIGINS`. +- Reset emails are sent from the backend only. No secrets are exposed to the frontend. +- Docker Mailserver access is treated as privileged infrastructure access. + +## 8. Run locally + +### Prerequisites + +- Bun installed +- Docker available locally +- A running Docker Mailserver container + +### Setup + +1. Copy `.env.example` to `.env`. +2. Update at least: + - `HOSTED_DOMAINS` + - `MAILSERVER_CONTAINER` + - `APP_ADMIN_EMAIL` + - `APP_ADMIN_PASSWORD` + - `APP_MAIL_FROM` +3. Install dependencies: ```bash -openssl rand -base64 32 +bun install ``` -### 2. Install dependencies - -If Bun needs an explicit temp dir on your machine: +4. Start the API: ```bash -BUN_TMPDIR=/tmp bun install +bun run dev:api ``` -### 3. Start the apps - -Backend: +5. In another terminal, start the frontend: ```bash -bun run --cwd apps/api dev +bun run dev:web ``` -Frontend: +6. Open `http://localhost:5173`. -```bash -bun run --cwd apps/web dev -``` +## Notes and assumptions -The Vite dev server proxies `/api` to `http://localhost:3001`. - -Important en local: - -- le compte admin local est créé au démarrage de l'API -- il faut donc avoir un fichier `.env` à la racine du repo -- et redémarrer l'API après tout changement de `APP_ADMIN_EMAIL` ou `APP_ADMIN_PASSWORD` - -## Docker CLI assumptions - -The backend assumes docker-mailserver is available in a running container and that the following commands are present: - -- `doveadm auth test` for mailbox authentication -- `find` and `cat` for safe Maildir reads -- `sendmail` for sending plain-text messages -- `setup alias add|del` for forwarding aliases - -The mailbox reader currently targets the standard Maildir layout: - -```text -/var/mail///Maildir -``` - -If your container layout differs, update `apps/api/src/docker/mailserver.ts`. - -## Production deployment guidance - -### Reverse proxy - -Use a reverse proxy such as Nginx or Traefik in front of both the frontend and API. - -- Terminate TLS at the proxy -- Forward only `/api` to the API container -- Serve the frontend over HTTPS only -- Strip hop-by-hop headers -- Preserve the real client IP only if you trust the proxy chain - -A hardened Nginx example lives at [infra/nginx/default.conf](/home/kisakay/Documents/GitLab/internal-mail-server/infra/nginx/default.conf). - -### Container and network hardening - -- Run the web and API containers as non-root users -- Mount only the minimum filesystem paths required -- Put docker-mailserver on an internal Docker network -- Do not expose unnecessary internal ports -- Restrict who can access the Docker socket; ideally expose a tightly scoped Docker API proxy instead of mounting the raw socket directly -- Set `TRUST_PROXY=true` only behind a trusted reverse proxy - -### TLS and browser protections - -- Enforce HTTPS everywhere -- Keep `Strict-Transport-Security` enabled in production -- Preserve `X-Frame-Options: DENY` -- Preserve CSP, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer` -- Avoid adding third-party scripts unless you also update CSP intentionally - -### Secret handling - -- Store secrets in environment variables or a secrets manager -- Rotate `TOTP_ENCRYPTION_KEY` carefully; rotating it invalidates stored TOTP secrets unless you build a migration path -- Never log plaintext credentials -- Treat Docker access as privileged infrastructure access - -## Verification - -Verified locally: - -- `bun run typecheck` in `packages/shared` -- `bun run typecheck` in `apps/api` -- `bun run typecheck` in `apps/web` -- `bun run build` in `apps/api` -- `bun run build` in `apps/web` - -## Residual risks and next steps - -- Direct Docker socket access remains a high-value target. The safest next step is replacing raw socket exposure with a restricted sidecar or service account boundary. -- Maildir parsing is intentionally conservative and currently optimized for `INBOX`. If you need full IMAP semantics, move message access behind a dedicated service abstraction instead of expanding ad hoc container reads. -- Forwarding rules are managed locally and then applied to docker-mailserver. If you need full bidirectional sync with existing aliases, add an audited reconciliation job. +- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands. +- The admin account for the panel is separate from mailbox users. +- The app stores only panel-specific metadata in SQLite. Mailbox accounts themselves remain managed by Docker Mailserver. diff --git a/apps/api/package.json b/apps/api/package.json index c82b182..52d5eb9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,15 +9,11 @@ }, "dependencies": { "@internal-mail/shared": "workspace:*", - "elysia": "^1.3.0", - "html-to-text": "^9.0.5", - "otpauth": "^9.4.1", - "postal-mime": "^2.4.4", - "sanitize-html": "^2.17.0", - "zod": "^3.24.2" + "elysia": "^1.3.10", + "zod": "^3.25.0" }, "devDependencies": { - "bun-types": "^1.3.11", + "bun-types": "^1.3.10", "typescript": "^5.8.3" } } diff --git a/apps/api/src/auth/service.ts b/apps/api/src/auth/service.ts index 9d9b3bd..fcb63fd 100644 --- a/apps/api/src/auth/service.ts +++ b/apps/api/src/auth/service.ts @@ -1,174 +1,100 @@ -import * as OTPAuth from "otpauth"; -import type { SessionView, UserRole } from "@internal-mail/shared"; -import { authenticateMailbox } from "../docker/mailserver"; +import type { AdminSession } from "@internal-mail/shared"; import { config } from "../lib/config"; -import { decryptAtRest, encryptAtRest } from "../lib/crypto"; -import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db"; +import { createSession, deleteSession, getAdmin, getSession, upsertAdmin } from "../lib/db"; import { HttpError } from "../lib/errors"; -import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation"; +import { + buildClearedCookieValue, + buildCookieValue, + createOpaqueToken, + hashToken, + readCookie +} from "../lib/security"; -const issuer = "Secure Webmail"; +const cookieMaxAgeSeconds = Math.floor(config.sessionTtlMs / 1000); -const createTotp = (email: string, secret: string): OTPAuth.TOTP => - new OTPAuth.TOTP({ - issuer, - label: email, - algorithm: "SHA1", - digits: 6, - period: 30, - secret: OTPAuth.Secret.fromBase32(secret) - }); - -const buildSessionView = (email: string, role: UserRole, csrfToken: string | null): SessionView => { - const profile = ensureProfile(email, role); - return { - authenticated: true, - csrfToken, - user: { - email: profile.email, - displayName: profile.display_name, - role, - avatarUrl: profile.avatar_path ? `/api/settings/avatar/${encodeURIComponent(profile.email)}` : null, - totpEnabled: Boolean(profile.totp_enabled) - } - }; -}; +const sessionView = (adminEmail: string | null): AdminSession => ({ + authenticated: Boolean(adminEmail), + adminEmail +}); export const seedAdminAccount = async (): Promise => { - if (!config.adminEmail || !config.adminPassword) { + if (!config.appAdminEmail || !config.appAdminPassword) { return; } - const existing = getAdmin(config.adminEmail); - if (existing && (await Bun.password.verify(config.adminPassword, existing.password_hash))) { - ensureProfile(config.adminEmail, "admin"); + const existing = getAdmin(config.appAdminEmail); + if (existing && (await Bun.password.verify(config.appAdminPassword, existing.password_hash))) { return; } - const passwordHash = await Bun.password.hash(config.adminPassword, { + const passwordHash = await Bun.password.hash(config.appAdminPassword, { algorithm: "argon2id", - memoryCost: 19456, + memoryCost: 19_456, timeCost: 2 }); - upsertAdmin(config.adminEmail, passwordHash); + upsertAdmin(config.appAdminEmail, passwordHash); }; -export const login = async ( - email: string, - password: string, - totpCode: string | undefined, - _request: Request -): Promise<{ session: SessionView }> => { - const normalizedEmail = strictEmailSchema.parse(email); - let role: UserRole = "user"; - - if (config.allowInsecureLogin) { - if (config.adminEmail && normalizedEmail === config.adminEmail) { - role = "admin"; - } - - return { - session: buildSessionView(normalizedEmail, role, null) - }; +export const loginAdmin = async (email: string, password: string): Promise<{ + session: AdminSession; + cookie: string; +}> => { + const admin = getAdmin(email); + if (!admin) { + throw new HttpError(401, "Invalid email or password."); } - const localAdmin = getAdmin(normalizedEmail); - if (localAdmin) { - const valid = await Bun.password.verify(password, localAdmin.password_hash); - if (!valid) { - throw new HttpError(401, "Invalid email or password."); - } - role = "admin"; - } else { - hostedMailboxSchema.parse(normalizedEmail); - const isValid = await authenticateMailbox(normalizedEmail, password); - if (!isValid) { - throw new HttpError(401, "Invalid email or password."); - } + const validPassword = await Bun.password.verify(password, admin.password_hash); + if (!validPassword) { + throw new HttpError(401, "Invalid email or password."); } - const profile = ensureProfile(normalizedEmail, role); - if (profile.totp_enabled) { - if (!totpCode) { - throw new HttpError(401, "Two-factor code is required."); - } + const rawToken = createOpaqueToken(); + const tokenHash = hashToken(rawToken); + const expiresAt = Date.now() + config.sessionTtlMs; - const decryptedSecret = await decryptAtRest(profile.totp_secret!); - const delta = createTotp(normalizedEmail, decryptedSecret).validate({ token: totpCode, window: 1 }); - if (delta === null) { - throw new HttpError(401, "Invalid two-factor code."); - } - } + createSession(tokenHash, admin.email, expiresAt); return { - session: buildSessionView(normalizedEmail, role, null) + session: sessionView(admin.email), + cookie: buildCookieValue(config.sessionCookieName, rawToken, { + maxAgeSeconds: cookieMaxAgeSeconds, + secure: config.isProduction + }) }; }; -export const getSessionFromRequest = async (request: Request) => { - const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail; - if (!headerEmail) { - throw new HttpError(401, "X-User-Email header is required."); +export const logoutAdmin = (request: Request): string => { + const rawToken = readCookie(request, config.sessionCookieName); + if (rawToken) { + deleteSession(hashToken(rawToken)); } - const email = strictEmailSchema.parse(headerEmail); - const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user"; - ensureProfile(email, role); + return buildClearedCookieValue(config.sessionCookieName, config.isProduction); +}; + +export const requireAdminSession = (request: Request): { adminEmail: string } => { + const rawToken = readCookie(request, config.sessionCookieName); + if (!rawToken) { + throw new HttpError(401, "Authentication required."); + } + + const session = getSession(hashToken(rawToken)); + if (!session || session.expires_at <= Date.now()) { + throw new HttpError(401, "Authentication required."); + } return { - id: "stateless", - email, - role, - csrfToken: null, - session: buildSessionView(email, role, null) + adminEmail: session.admin_email }; }; -export const getAnonymousSession = (): SessionView => ({ - authenticated: false, - csrfToken: null, - user: null -}); - -export const createTotpSecret = async (email: string): Promise<{ secret: string; otpauthUrl: string }> => { - const profile = getProfile(email) ?? ensureProfile(email); - const secret = new OTPAuth.Secret({ size: 20 }).base32; - const encrypted = await encryptAtRest(secret); - setTotpSecret(profile.email, encrypted, false); - - return { - secret, - otpauthUrl: createTotp(email, secret).toString() - }; +export const getSessionFromRequest = (request: Request): AdminSession => { + try { + return sessionView(requireAdminSession(request).adminEmail); + } catch { + return sessionView(null); + } }; -export const enableTotp = async (email: string, code: string): Promise => { - const profile = getProfile(email) ?? ensureProfile(email); - if (!profile.totp_secret) { - throw new HttpError(400, "Set up two-factor authentication first."); - } - - const secret = await decryptAtRest(profile.totp_secret); - const delta = createTotp(email, secret).validate({ token: code, window: 1 }); - if (delta === null) { - throw new HttpError(400, "Invalid two-factor code."); - } - - setTotpSecret(email, profile.totp_secret, true); -}; - -export const disableTotp = async (email: string, code: string): Promise => { - const profile = getProfile(email) ?? ensureProfile(email); - if (!profile.totp_enabled || !profile.totp_secret) { - throw new HttpError(400, "Two-factor authentication is not enabled."); - } - - const secret = await decryptAtRest(profile.totp_secret); - const delta = createTotp(email, secret).validate({ token: code, window: 1 }); - if (delta === null) { - throw new HttpError(400, "Invalid two-factor code."); - } - - setTotpSecret(email, null, false); -}; diff --git a/apps/api/src/docker/mailserver.ts b/apps/api/src/docker/mailserver.ts index 6235e95..b4e5eeb 100644 --- a/apps/api/src/docker/mailserver.ts +++ b/apps/api/src/docker/mailserver.ts @@ -1,104 +1,43 @@ -import { config } from "../lib/config"; -import { HttpError } from "../lib/errors"; -import { hostedMailboxSchema } from "../lib/validation"; +import type { MailUser } from "@internal-mail/shared"; import { runDocker } from "./runner"; -const MAILDIR_FOLDER = "INBOX"; -const infrastructureErrorPatterns = [ - /cannot connect to the docker daemon/i, - /permission denied/i, - /no such container/i, - /is not running/i, - /executable file not found/i, - /no such file or directory/i, - /command not found/i -]; +const emailPattern = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; -const looksLikeInfrastructureFailure = (output: string): boolean => - infrastructureErrorPatterns.some((pattern) => pattern.test(output)); +const toMailUser = (email: string): MailUser => { + const [rawLocalPart, rawDomain] = email.split("@"); + const localPart = rawLocalPart ?? email; + const domain = rawDomain ?? ""; -const localPartRegex = /^[A-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}$/i; + return { + email, + localPart, + domain + }; +}; -const splitHostedEmail = (email: string): { localPart: string; domain: string } => { - const normalized = hostedMailboxSchema.parse(email); - const [localPart, domain] = normalized.split("@"); - if (!localPart || !domain || !localPartRegex.test(localPart)) { - throw new HttpError(400, "Unsupported mailbox format."); +export const listMailUsers = async (): Promise => { + const { stdout } = await runDocker(["setup", "email", "list"]); + const emails = new Set(); + + for (const match of stdout.matchAll(emailPattern)) { + emails.add(match[0].toLowerCase()); } - return { localPart, domain }; + return [...emails].sort().map(toMailUser); }; -const buildMaildirRoot = (email: string): string => { - const { localPart, domain } = splitHostedEmail(email); - return `/var/mail/${domain}/${localPart}/Maildir`; +export const addMailUser = async (email: string, password: string): Promise => { + await runDocker(["setup", "email", "add", email, password]); }; -export const authenticateMailbox = async (email: string, password: string): Promise => { - const normalizedEmail = hostedMailboxSchema.parse(email); - const result = await runDocker( - ["exec", config.mailserverContainer, "doveadm", "auth", "test", normalizedEmail, password], - { allowNonZero: true } - ); - - const combinedOutput = `${result.stdout}\n${result.stderr}`.trim(); - if (result.exitCode !== 0 && looksLikeInfrastructureFailure(combinedOutput)) { - throw new HttpError(502, "Mail authentication backend is unavailable."); - } - - return result.exitCode === 0; +export const updateMailUserPassword = async (email: string, password: string): Promise => { + await runDocker(["setup", "email", "update", email, password]); }; -export const listInboxFiles = async (email: string): Promise => { - const root = buildMaildirRoot(email); - const result = await runDocker([ - "exec", - config.mailserverContainer, - "find", - `${root}/cur`, - `${root}/new`, - "-maxdepth", - "1", - "-type", - "f" - ]); - - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .sort() - .reverse(); +export const deleteMailUser = async (email: string): Promise => { + await runDocker(["setup", "email", "del", email]); }; -export const readMailboxFile = async (containerPath: string): Promise => { - if (!containerPath.startsWith("/var/mail/")) { - throw new HttpError(400, "Invalid mail file path."); - } - - const result = await runDocker(["exec", config.mailserverContainer, "cat", containerPath]); - return result.stdout; +export const sendMail = async (rawMessage: string): Promise => { + await runDocker(["sendmail", "-t", "-i"], rawMessage); }; - -export const sendMessage = async (sender: string, recipients: string[], rawMessage: string): Promise => { - if (!recipients.length) { - throw new HttpError(400, "At least one recipient is required."); - } - - await runDocker( - ["exec", "-i", config.mailserverContainer, "sendmail", "-i", "-f", sender, ...recipients], - { stdinText: rawMessage } - ); -}; - -export const addForwardingAlias = async (sourceEmail: string, targetEmail: string): Promise => { - await runDocker(["exec", config.mailserverContainer, "setup", "alias", "add", sourceEmail, targetEmail]); -}; - -export const removeForwardingAlias = async (sourceEmail: string, targetEmail: string): Promise => { - await runDocker(["exec", config.mailserverContainer, "setup", "alias", "del", sourceEmail, targetEmail], { - allowNonZero: true - }); -}; - -export const getSupportedFolder = (): string => MAILDIR_FOLDER; diff --git a/apps/api/src/docker/runner.ts b/apps/api/src/docker/runner.ts index a5e13ae..39775a7 100644 --- a/apps/api/src/docker/runner.ts +++ b/apps/api/src/docker/runner.ts @@ -1,67 +1,70 @@ +import { spawn } from "node:child_process"; import { config } from "../lib/config"; import { HttpError } from "../lib/errors"; -type DockerRunOptions = { - stdinText?: string; - allowNonZero?: boolean; -}; - -export type DockerRunResult = { +type DockerResult = { stdout: string; stderr: string; - exitCode: number; }; -export class DockerCommandError extends Error { - readonly exitCode: number; - readonly stderr: string; - - constructor(message: string, exitCode: number, stderr: string) { - super(message); - this.name = "DockerCommandError"; - this.exitCode = exitCode; - this.stderr = stderr; - } -} - -export const runDocker = async (args: string[], options?: DockerRunOptions): Promise => { - const process = Bun.spawn({ - cmd: ["docker", ...args], - stdout: "pipe", - stderr: "pipe", - stdin: options?.stdinText ? "pipe" : "ignore", - env: {} - }); - - const timeout = setTimeout(() => { - process.kill(); - }, config.mailserverTimeoutMs); +export const runDocker = async ( + args: string[], + input?: string +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.mailserverTimeoutMs); try { - if (options?.stdinText && process.stdin) { - await process.stdin.write(new TextEncoder().encode(options.stdinText)); - await process.stdin.end(); + const child = spawn( + config.dockerBinary, + ["exec", ...(input ? ["-i"] : []), config.mailserverContainer, ...args], + { + shell: false, + signal: controller.signal, + stdio: ["pipe", "pipe", "pipe"] + } + ); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + if (input) { + child.stdin.write(input); } - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - process.exited - ]); + child.stdin.end(); - if (exitCode !== 0 && !options?.allowNonZero) { - throw new DockerCommandError("Docker command failed.", exitCode, stderr); + const exitCode = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => resolve(code ?? 1)); + }); + + if (exitCode !== 0) { + throw new HttpError(502, stderr.trim() || stdout.trim() || "Docker Mailserver command failed."); } - return { stdout, stderr, exitCode }; + return { + stdout, + stderr + }; } catch (error) { - if (error instanceof DockerCommandError) { + if (error instanceof HttpError) { throw error; } - throw new HttpError(504, "Docker command timed out or failed to execute.", { - expose: false - }); + if (error instanceof Error && error.name === "AbortError") { + throw new HttpError(504, "Docker Mailserver command timed out."); + } + + throw new HttpError(502, "Docker Mailserver is unreachable."); } finally { clearTimeout(timeout); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4e9b2fc..29c985c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,21 +1,43 @@ import { Elysia } from "elysia"; -import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service"; -import { HttpError } from "./lib/errors"; -import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http"; +import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, seedAdminAccount } from "./auth/service"; +import { cleanupDatabase } from "./lib/db"; +import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http"; import { config } from "./lib/config"; -import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security"; -import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation"; -import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service"; -import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service"; +import { + createUserSchema, + loginSchema, + parseRouteEmail, + resetPasswordSchema, + resetTokenSchema, + updatePasswordSchema +} from "./lib/validation"; +import { + consumePasswordReset, + createUser, + deleteUser, + inspectPasswordReset, + listUsers, + triggerPasswordReset, + updateUserPassword +} from "./users/service"; const app = new Elysia(); -const route = (handler: (request: Request, params?: Record) => Promise | Response) => +const route = ( + handler: (context: { + request: Request; + params: Record; + }) => Promise | Response +) => async ({ request, params }: { request: Request; params: Record }) => { + if (request.method === "OPTIONS") { + return optionsResponse(request); + } + try { assertAllowedOrigin(request); - enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs); - return await handler(request, params); + cleanupDatabase(); + return await handler({ request, params }); } catch (error) { return errorResponse(request, error); } @@ -23,179 +45,113 @@ const route = (handler: (request: Request, params?: Record) => P await seedAdminAccount(); -app.onRequest(({ request }) => { - if (request.method === "OPTIONS") { - return emptyResponse(request, 204); - } -}); - app.get( "/api/health", - route(async (request) => + route(async ({ request }) => jsonResponse(request, 200, { - ok: true, - mode: config.nodeEnv, - folder: getMailboxFolderLabel() + ok: true }) ) ); app.get( "/api/auth/session", - route(async (request) => { - try { - const session = await getSessionFromRequest(request); - return jsonResponse(request, 200, { session: session.session }); - } catch { - return jsonResponse(request, 200, { session: getAnonymousSession() }); - } - }) + route(async ({ request }) => + jsonResponse(request, 200, { + session: getSessionFromRequest(request) + }) + ) ); app.post( "/api/auth/login", - route(async (request) => { - if (!config.allowInsecureLogin) { - enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs); - } + route(async ({ request }) => { const body = await parseJson(request, loginSchema); - const result = await login(body.email, body.password, body.totpCode, request); + const result = await loginAdmin(body.email, body.password); + return jsonResponse( request, 200, - { session: result.session } + { session: result.session }, + { "set-cookie": result.cookie } ); }) ); app.post( "/api/auth/logout", - route(async (request) => { - return jsonResponse(request, 200, { session: getAnonymousSession() }); - }) -); - -app.post( - "/api/auth/totp/setup", - route(async (request) => { - const session = await getSessionFromRequest(request); - const setup = await createTotpSecret(session.email); - return jsonResponse(request, 200, setup); - }) -); - -app.post( - "/api/auth/totp/enable", - route(async (request) => { - const session = await getSessionFromRequest(request); - const body = await parseJson(request, totpCodeSchema); - await enableTotp(session.email, body.code); - return emptyResponse(request, 204); - }) -); - -app.post( - "/api/auth/totp/disable", - route(async (request) => { - const session = await getSessionFromRequest(request); - const body = await parseJson(request, totpCodeSchema); - await disableTotp(session.email, body.code); - return emptyResponse(request, 204); - }) + route(async ({ request }) => + jsonResponse( + request, + 200, + { session: { authenticated: false, adminEmail: null } }, + { "set-cookie": logoutAdmin(request) } + ) + ) ); app.get( - "/api/settings/profile", - route(async (request) => { - const session = await getSessionFromRequest(request); - return jsonResponse(request, 200, getProfileSettings(session.email)); + "/api/users", + route(async ({ request }) => { + requireAdminSession(request); + return jsonResponse(request, 200, { items: await listUsers() }); + }) +); + +app.post( + "/api/users", + route(async ({ request }) => { + const session = requireAdminSession(request); + const body = await parseJson(request, createUserSchema); + const result = await createUser(body.email, session.adminEmail); + return jsonResponse(request, 201, result); }) ); app.patch( - "/api/settings/profile", - route(async (request) => { - const session = await getSessionFromRequest(request); - const body = await parseJson(request, profileSchema); - return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName)); + "/api/users/:email/password", + route(async ({ request, params }) => { + requireAdminSession(request); + const email = parseRouteEmail(params.email); + const body = await parseJson(request, updatePasswordSchema); + await updateUserPassword(email, body.password); + return emptyResponse(request, 204); + }) +); + +app.delete( + "/api/users/:email", + route(async ({ request, params }) => { + requireAdminSession(request); + const email = parseRouteEmail(params.email); + await deleteUser(email); + return emptyResponse(request, 204); }) ); app.post( - "/api/settings/avatar", - route(async (request) => { - const session = await getSessionFromRequest(request); - const formData = await request.formData(); - const avatar = formData.get("avatar"); - if (!(avatar instanceof File)) { - throw new HttpError(400, "Avatar upload is required."); - } - - const profile = await saveAvatar(session.email, avatar); - return jsonResponse(request, 200, profile); - }) -); - -app.get( - "/api/settings/avatar/:email", - route(async (request, params) => { - const session = await getSessionFromRequest(request); - const targetEmail = strictEmailSchema.parse(params?.email ?? ""); - if (targetEmail !== session.email && session.role !== "admin") { - throw new HttpError(403, "You can only access your own avatar."); - } - - const file = getAvatarFile(targetEmail); - return fileResponse(request, file, file.type || "application/octet-stream"); - }) -); - -app.get( - "/api/settings/forwarding", - route(async (request) => { - const session = await getSessionFromRequest(request); - return jsonResponse(request, 200, getForwardingSettings(session.email)); - }) -); - -app.put( - "/api/settings/forwarding", - route(async (request) => { - const session = await getSessionFromRequest(request); - const body = await parseJson(request, forwardingSchema); - const rule = await updateForwarding(session.email, body.enabled, body.target); - return jsonResponse(request, 200, rule); - }) -); - -app.get( - "/api/mail/messages", - route(async (request) => { - const session = await getSessionFromRequest(request); - enforceLimiter(routeLimiter, request, "messages", config.routeRateLimit, config.rateLimitWindowMs, session.email); - const items = await listMessagesForMailbox(session.email); - return jsonResponse(request, 200, { items }); - }) -); - -app.get( - "/api/mail/messages/:id", - route(async (request, params) => { - const session = await getSessionFromRequest(request); - enforceLimiter(routeLimiter, request, "message-detail", config.routeRateLimit, config.rateLimitWindowMs, session.email); - const { id } = messageIdSchema.parse({ id: params?.id ?? "" }); - const item = await getMessageForMailbox(session.email, id); - return jsonResponse(request, 200, item); + "/api/users/:email/password-reset", + route(async ({ request, params }) => { + const session = requireAdminSession(request); + const email = parseRouteEmail(params.email); + const result = await triggerPasswordReset(email, session.adminEmail); + return jsonResponse(request, 200, result); }) ); app.post( - "/api/mail/send", - route(async (request) => { - const session = await getSessionFromRequest(request); - enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email); - const body = await parseJson(request, composeSchema); - await composeAndSendMessage(session.email, body.to, body.subject, body.textBody); + "/api/password-resets/inspect", + route(async ({ request }) => { + const body = await parseJson(request, resetTokenSchema); + return jsonResponse(request, 200, await inspectPasswordReset(body.token)); + }) +); + +app.post( + "/api/password-resets/consume", + route(async ({ request }) => { + const body = await parseJson(request, resetPasswordSchema); + await consumePasswordReset(body.token, body.password); return emptyResponse(request, 204); }) ); @@ -205,4 +161,4 @@ app.listen({ port: config.port }); -console.log(`Secure webmail API listening on http://${config.host}:${config.port}`); +console.log(`Mail admin API listening on http://${config.host}:${config.port}`); diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 6730af6..0aa4f62 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -1,73 +1,36 @@ import { resolve } from "node:path"; -import { z } from "zod"; -const normalizedEnv = Object.fromEntries( - Object.entries(Bun.env).map(([key, value]) => [key, value === "" ? undefined : value]) -); +const env = Bun.env; -const splitList = (value: string | undefined, fallback: string): string[] => - (value ?? fallback) +const parseList = (value: string | undefined, fallback: string[]): string[] => + (value ?? "") .split(",") - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + .concat(fallback) + .filter((item, index, items) => items.indexOf(item) === index); -const envSchema = z.object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - APP_HOST: z.string().default("0.0.0.0"), - APP_PORT: z.coerce.number().int().min(1).max(65535).default(3001), - APP_ORIGINS: z.string().default("http://localhost:5173"), - APP_EXTERNAL_BASE_URL: z.string().url().default("http://localhost:5173"), - DATA_DIR: z.string().default("./data"), - MAILSERVER_CONTAINER: z - .string() - .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/) - .default("mailserver"), - MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000), - HOSTED_DOMAINS: z.string().default("example.com"), - FORWARDING_ALLOWED_DOMAINS: z.string().optional(), - GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240), - ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90), - RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60), - LOGIN_RATE_LIMIT: z.coerce.number().int().min(3).max(20).default(5), - LOGIN_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86400).default(600), - MAX_MESSAGES: z.coerce.number().int().min(1).max(100).default(25), - AVATAR_MAX_BYTES: z.coerce.number().int().min(1024).max(5_000_000).default(2_097_152), - TRUST_PROXY: z.enum(["true", "false"]).default("false"), - APP_ADMIN_EMAIL: z.string().email().optional(), - APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(), - TOTP_ENCRYPTION_KEY: z.string().optional(), - ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false") -}); - -const raw = envSchema.parse(normalizedEnv); - -if ((raw.APP_ADMIN_EMAIL && !raw.APP_ADMIN_PASSWORD) || (!raw.APP_ADMIN_EMAIL && raw.APP_ADMIN_PASSWORD)) { - throw new Error("APP_ADMIN_EMAIL and APP_ADMIN_PASSWORD must be provided together."); -} +const parseNumber = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; export const config = { - nodeEnv: raw.NODE_ENV, - host: raw.APP_HOST, - port: raw.APP_PORT, - externalBaseUrl: raw.APP_EXTERNAL_BASE_URL, - allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")), - dataDir: resolve(process.cwd(), raw.DATA_DIR), - avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"), - dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"), - mailserverContainer: raw.MAILSERVER_CONTAINER, - mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS, - hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"), - forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS), - globalRateLimit: raw.GLOBAL_RATE_LIMIT, - routeRateLimit: raw.ROUTE_RATE_LIMIT, - rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000, - loginRateLimit: raw.LOGIN_RATE_LIMIT, - loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000, - maxMessages: raw.MAX_MESSAGES, - avatarMaxBytes: raw.AVATAR_MAX_BYTES, - trustProxy: raw.TRUST_PROXY === "true", - adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null, - adminPassword: raw.APP_ADMIN_PASSWORD ?? null, - totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null, - allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true" -} as const; + nodeEnv: env.NODE_ENV ?? "development", + host: env.APP_HOST ?? "0.0.0.0", + port: parseNumber(env.APP_PORT, 3001), + appOrigins: parseList(env.APP_ORIGINS, ["http://localhost:5173"]), + externalBaseUrl: (env.APP_EXTERNAL_BASE_URL ?? "http://localhost:5173").replace(/\/+$/, ""), + dbPath: resolve(process.cwd(), env.APP_DATA_PATH ?? "../../data/app.db"), + mailserverContainer: env.MAILSERVER_CONTAINER ?? "mailserver", + dockerBinary: env.DOCKER_BINARY ?? "docker", + mailserverTimeoutMs: parseNumber(env.MAILSERVER_TIMEOUT_MS, 10_000), + hostedDomains: parseList(env.HOSTED_DOMAINS, []), + sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session", + sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000, + resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000, + appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "", + appAdminPassword: env.APP_ADMIN_PASSWORD ?? "", + appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "no-reply@example.com", + isProduction: (env.NODE_ENV ?? "development") === "production" +}; diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts deleted file mode 100644 index c8f705d..0000000 --- a/apps/api/src/lib/crypto.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Buffer } from "node:buffer"; -import { config } from "./config"; -import { HttpError } from "./errors"; - -const encoder = new TextEncoder(); -let cachedAesKey: CryptoKey | null = null; - -const importAesKey = async (): Promise => { - if (!config.totpEncryptionKey) { - throw new HttpError(503, "Two-factor encryption key is not configured.", { expose: true }); - } - - if (cachedAesKey) { - return cachedAesKey; - } - - const rawKey = Buffer.from(config.totpEncryptionKey, "base64"); - if (rawKey.byteLength !== 32) { - throw new Error("TOTP_ENCRYPTION_KEY must decode to 32 bytes."); - } - - cachedAesKey = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt", "decrypt"]); - return cachedAesKey; -}; - -export const randomToken = (bytes = 32): string => Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url"); - -export const sha256Hex = async (value: string): Promise => { - const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); - return Buffer.from(digest).toString("hex"); -}; - -export const encryptAtRest = async (value: string): Promise => { - const key = await importAesKey(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - encoder.encode(value) - ); - - return `${Buffer.from(iv).toString("base64")}.${Buffer.from(encrypted).toString("base64")}`; -}; - -export const decryptAtRest = async (payload: string): Promise => { - const [ivPart, dataPart] = payload.split("."); - if (!ivPart || !dataPart) { - throw new Error("Invalid encrypted payload."); - } - - const key = await importAesKey(); - const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: Buffer.from(ivPart, "base64") }, - key, - Buffer.from(dataPart, "base64") - ); - - return Buffer.from(decrypted).toString("utf8"); -}; - diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index 2e55e8d..75415f9 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -1,20 +1,8 @@ import { mkdirSync } from "node:fs"; -import { unlink } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; +import { dirname } from "node:path"; import { Database } from "bun:sqlite"; import { config } from "./config"; -export type ProfileRow = { - email: string; - display_name: string; - avatar_path: string | null; - role: "admin" | "user"; - totp_secret: string | null; - totp_enabled: number; - created_at: number; - updated_at: number; -}; - type AdminRow = { email: string; password_hash: string; @@ -22,31 +10,28 @@ type AdminRow = { updated_at: number; }; -type ForwardingRow = { - email: string; - target_email: string | null; - enabled: number; - updated_at: number; +type SessionRow = { + token_hash: string; + admin_email: string; + expires_at: number; + created_at: number; +}; + +export type PasswordResetTokenRow = { + 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(config.avatarDir, { recursive: true }); const db = new Database(config.dbPath, { create: true }); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec(` - CREATE TABLE IF NOT EXISTS user_profiles ( - email TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - avatar_path TEXT, - role TEXT NOT NULL CHECK(role IN ('admin', 'user')), - totp_secret TEXT, - totp_enabled INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS app_admins ( email TEXT PRIMARY KEY, password_hash TEXT NOT NULL, @@ -54,111 +39,140 @@ db.exec(` updated_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS forwarding_rules ( - email TEXT PRIMARY KEY, - target_email TEXT, - enabled INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL + CREATE TABLE IF NOT EXISTS app_sessions ( + token_hash TEXT PRIMARY KEY, + admin_email TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_hash TEXT PRIMARY KEY, + user_email TEXT NOT NULL, + created_by TEXT NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at + ON app_sessions(expires_at); + + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_email + ON password_reset_tokens(user_email); + + CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at + ON password_reset_tokens(expires_at); `); -const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email; -const toAbsoluteAvatarPath = (avatarPath: string | null): string | null => - avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null; - -const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1"); -const insertProfileStatement = db.query(` - INSERT OR IGNORE INTO user_profiles ( - email, - display_name, - avatar_path, - role, - totp_secret, - totp_enabled, - created_at, - updated_at - ) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4) -`); -const updateProfileStatement = db.query(` - UPDATE user_profiles - SET display_name = ?1, updated_at = ?2 - WHERE email = ?3 -`); -const updateAvatarStatement = db.query(` - UPDATE user_profiles - SET avatar_path = ?1, updated_at = ?2 - WHERE email = ?3 -`); -const updateTotpStatement = db.query(` - UPDATE user_profiles - SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3 - WHERE email = ?4 -`); const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1"); const upsertAdminStatement = db.query(` INSERT INTO app_admins (email, password_hash, created_at, updated_at) VALUES (?1, ?2, ?3, ?3) - ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at + ON CONFLICT(email) DO UPDATE SET + password_hash = excluded.password_hash, + updated_at = excluded.updated_at `); -const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1"); -const upsertForwardingStatement = db.query(` - INSERT INTO forwarding_rules (email, target_email, enabled, updated_at) + +const insertSessionStatement = db.query(` + INSERT INTO app_sessions (token_hash, admin_email, expires_at, created_at) VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at +`); +const getSessionStatement = db.query("SELECT * FROM app_sessions WHERE token_hash = ?1"); +const deleteSessionStatement = db.query("DELETE FROM app_sessions WHERE token_hash = ?1"); +const deleteExpiredSessionsStatement = db.query("DELETE FROM app_sessions WHERE expires_at <= ?1"); + +const insertResetTokenStatement = db.query(` + INSERT INTO password_reset_tokens (token_hash, user_email, created_by, expires_at, used_at, created_at) + VALUES (?1, ?2, ?3, ?4, NULL, ?5) +`); +const deleteResetTokenStatement = db.query("DELETE FROM password_reset_tokens WHERE token_hash = ?1"); +const deleteActiveResetTokensForUserStatement = db.query(` + DELETE FROM password_reset_tokens + WHERE user_email = ?1 + AND used_at IS NULL +`); +const getActiveResetTokenStatement = db.query(` + SELECT * FROM password_reset_tokens + WHERE token_hash = ?1 + AND used_at IS NULL + AND expires_at > ?2 +`); +const claimResetTokenStatement = db.query(` + UPDATE password_reset_tokens + SET used_at = ?2 + WHERE token_hash = ?1 + AND used_at IS NULL + AND expires_at > ?3 +`); +const releaseResetTokenStatement = db.query(` + UPDATE password_reset_tokens + SET used_at = NULL + WHERE token_hash = ?1 +`); +const deleteExpiredResetTokensStatement = db.query(` + DELETE FROM password_reset_tokens + WHERE expires_at <= ?1 + OR (used_at IS NOT NULL AND used_at <= ?2) `); -export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null; - -export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => { - const now = Date.now(); - insertProfileStatement.run(email, defaultDisplayName(email), role, now); - return getProfile(email)!; -}; - -export const updateProfile = (email: string, displayName: string): ProfileRow => { - updateProfileStatement.run(displayName, Date.now(), email); - return getProfile(email)!; -}; - -export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => { - updateAvatarStatement.run(avatarPath, Date.now(), email); - return getProfile(email)!; -}; - -export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => { - updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email); - return getProfile(email)!; -}; - -export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null; +export const getAdmin = (email: string): AdminRow | null => + (getAdminStatement.get(email) as AdminRow | null) ?? null; export const upsertAdmin = (email: string, passwordHash: string): void => { - upsertAdminStatement.run(email, passwordHash, Date.now()); - ensureProfile(email, "admin"); -}; - -export const getForwardingRule = (email: string): ForwardingRow | null => - (getForwardingStatement.get(email) as ForwardingRow | null) ?? null; - -export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => { const now = Date.now(); - upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now); - return getForwardingRule(email)!; + upsertAdminStatement.run(email, passwordHash, now); }; -export const deleteAvatarFile = async (avatarPath: string | null): Promise => { - if (!avatarPath) { - return; - } - - const absolutePath = toAbsoluteAvatarPath(avatarPath); - if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) { - return; - } - - try { - await unlink(absolutePath); - } catch { - // Best-effort cleanup of replaced avatars. - } +export const createSession = (tokenHash: string, adminEmail: string, expiresAt: number): void => { + insertSessionStatement.run(tokenHash, adminEmail, expiresAt, Date.now()); +}; + +export const getSession = (tokenHash: string): SessionRow | null => + (getSessionStatement.get(tokenHash) as SessionRow | null) ?? null; + +export const deleteSession = (tokenHash: string): void => { + deleteSessionStatement.run(tokenHash); +}; + +export const createPasswordResetToken = (input: { + tokenHash: string; + userEmail: string; + createdBy: string; + expiresAt: number; +}): void => { + insertResetTokenStatement.run( + input.tokenHash, + input.userEmail, + input.createdBy, + input.expiresAt, + Date.now() + ); +}; + +export const deletePasswordResetToken = (tokenHash: string): void => { + deleteResetTokenStatement.run(tokenHash); +}; + +export const deleteActivePasswordResetTokensForUser = (userEmail: string): void => { + deleteActiveResetTokensForUserStatement.run(userEmail); +}; + +export const getActivePasswordResetToken = (tokenHash: string): PasswordResetTokenRow | null => + (getActiveResetTokenStatement.get(tokenHash, Date.now()) as PasswordResetTokenRow | null) ?? null; + +export const claimPasswordResetToken = (tokenHash: string): boolean => { + const usedAt = Date.now(); + const result = claimResetTokenStatement.run(tokenHash, usedAt, usedAt); + return result.changes > 0; +}; + +export const releasePasswordResetToken = (tokenHash: string): void => { + releaseResetTokenStatement.run(tokenHash); +}; + +export const cleanupDatabase = (): void => { + const now = Date.now(); + deleteExpiredSessionsStatement.run(now); + deleteExpiredResetTokensStatement.run(now, now - 86_400_000); }; diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 3b37f61..ff8c9d7 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -1,16 +1,8 @@ export class HttpError extends Error { readonly status: number; - readonly expose: boolean; - readonly details?: unknown; - constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) { + constructor(status: number, message: string) { super(message); - this.name = "HttpError"; this.status = status; - this.expose = options?.expose ?? true; - this.details = options?.details; } } - -export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError; - diff --git a/apps/api/src/lib/http.ts b/apps/api/src/lib/http.ts index 6a3b617..894add1 100644 --- a/apps/api/src/lib/http.ts +++ b/apps/api/src/lib/http.ts @@ -1,77 +1,118 @@ +import { ZodError, type ZodSchema } from "zod"; import { config } from "./config"; -import { HttpError, isHttpError } from "./errors"; -import { buildResponseHeaders } from "./security"; +import { HttpError } from "./errors"; -export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response => +const allowedOrigin = (request: Request): string | null => { + const origin = request.headers.get("origin"); + if (!origin) { + return null; + } + + return config.appOrigins.includes(origin.toLowerCase()) ? origin : null; +}; + +const securityHeaders = (): Record => ({ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer" +}); + +const mergeHeaders = (request: Request, headers?: HeadersInit): Headers => { + const result = new Headers(headers); + const origin = allowedOrigin(request); + + for (const [key, value] of Object.entries(securityHeaders())) { + result.set(key, value); + } + + result.set("Vary", "Origin"); + + if (origin) { + result.set("Access-Control-Allow-Origin", origin); + result.set("Access-Control-Allow-Credentials", "true"); + result.set("Access-Control-Allow-Headers", "Content-Type"); + result.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS"); + } + + return result; +}; + +export const assertAllowedOrigin = (request: Request): void => { + const origin = request.headers.get("origin"); + if (!origin) { + return; + } + + if (!config.appOrigins.includes(origin.toLowerCase())) { + throw new HttpError(403, "Origin is not allowed."); + } +}; + +export const jsonResponse = ( + request: Request, + status: number, + payload: unknown, + headers?: HeadersInit +): Response => new Response(JSON.stringify(payload), { status, - headers: buildResponseHeaders(request, { - "Content-Type": "application/json; charset=utf-8", - ...init + headers: mergeHeaders(request, { + "content-type": "application/json; charset=utf-8", + ...headers }) }); -export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response => +export const emptyResponse = (request: Request, status = 204, headers?: HeadersInit): Response => new Response(null, { status, - headers: buildResponseHeaders(request, init) + headers: mergeHeaders(request, headers) }); -export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response => - new Response(body as BodyInit, { - status: 200, - headers: buildResponseHeaders(request, { - "Content-Type": contentType, - "Cache-Control": "private, max-age=300", - ...init - }) - }); +export const optionsResponse = (request: Request): Response => + emptyResponse(request, 204); -export const parseJson = async (request: Request, schema: { parse(data: unknown): T }): Promise => { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - throw new HttpError(415, "Expected application/json request body."); - } +export const parseJson = async (request: Request, schema: ZodSchema): Promise => { + let body: unknown; - let payload: unknown; try { - payload = await request.json(); + body = await request.json(); } catch { - throw new HttpError(400, "Malformed JSON request body."); + throw new HttpError(400, "Request body must be valid JSON."); } try { - return schema.parse(payload); + return schema.parse(body); } catch (error) { - throw new HttpError(400, "Request validation failed.", { details: error }); + if (error instanceof ZodError) { + throw new HttpError(400, error.issues[0]?.message ?? "Invalid request body."); + } + throw error; } }; export const errorResponse = (request: Request, error: unknown): Response => { - if (isHttpError(error)) { - const retryAfterMs = - typeof error.details === "object" && - error.details !== null && - "retryAfterMs" in error.details && - typeof (error.details as { retryAfterMs?: unknown }).retryAfterMs === "number" - ? Math.ceil(((error.details as { retryAfterMs: number }).retryAfterMs ?? 0) / 1000) - : undefined; - - return jsonResponse( - request, - error.status, - { - error: error.expose ? error.message : "An internal server error occurred." - }, - retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined - ); + if (error instanceof HttpError) { + return jsonResponse(request, error.status, { + error: { + message: error.message + } + }); } - if (config.nodeEnv !== "production") { - console.error(error); + if (error instanceof ZodError) { + return jsonResponse(request, 400, { + error: { + message: error.issues[0]?.message ?? "Invalid request." + } + }); } + console.error(error); + return jsonResponse(request, 500, { - error: "An internal server error occurred." + error: { + message: "Unexpected server error." + } }); }; + diff --git a/apps/api/src/lib/security.ts b/apps/api/src/lib/security.ts index 03d0c11..66ca05a 100644 --- a/apps/api/src/lib/security.ts +++ b/apps/api/src/lib/security.ts @@ -1,119 +1,56 @@ -import { config } from "./config"; -import { HttpError } from "./errors"; +import { createHash, randomBytes } from "node:crypto"; -type RateLimitResult = { - allowed: boolean; - retryAfterMs: number; -}; +export const hashToken = (value: string): string => + createHash("sha256").update(value).digest("hex"); -type RateBucket = { - count: number; - resetAt: number; -}; +export const createOpaqueToken = (size = 32): string => + randomBytes(size).toString("hex"); -export class SlidingWindowLimiter { - private readonly buckets = new Map(); +export const createTemporaryPassword = (): string => + randomBytes(24).toString("base64url"); - consume(key: string, max: number, windowMs: number): RateLimitResult { - const now = Date.now(); - const current = this.buckets.get(key); - - if (!current || current.resetAt <= now) { - this.buckets.set(key, { count: 1, resetAt: now + windowMs }); - return { allowed: true, retryAfterMs: 0 }; - } - - if (current.count >= max) { - return { allowed: false, retryAfterMs: Math.max(current.resetAt - now, 1000) }; - } - - current.count += 1; - return { allowed: true, retryAfterMs: 0 }; +export const buildCookieValue = ( + name: string, + value: string, + options: { + maxAgeSeconds: number; + secure: boolean; } -} +): string => { + const parts = [ + `${name}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "SameSite=Strict", + `Max-Age=${options.maxAgeSeconds}` + ]; -export const globalLimiter = new SlidingWindowLimiter(); -export const routeLimiter = new SlidingWindowLimiter(); -export const loginLimiter = new SlidingWindowLimiter(); + if (options.secure) { + parts.push("Secure"); + } -export const getClientIp = (request: Request): string => { - if (config.trustProxy) { - const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0]!.trim(); - } + return parts.join("; "); +}; - const realIp = request.headers.get("x-real-ip"); - if (realIp?.trim()) { - return realIp.trim(); +export const buildClearedCookieValue = (name: string, secure: boolean): string => + buildCookieValue(name, "", { maxAgeSeconds: 0, secure }); + +export const readCookie = (request: Request, name: string): string | null => { + const raw = request.headers.get("cookie"); + if (!raw) { + return null; + } + + for (const part of raw.split(";")) { + const [cookieName, ...rest] = part.trim().split("="); + if (cookieName === name) { + return decodeURIComponent(rest.join("=")); } } - return "0.0.0.0"; + return null; }; -export const assertAllowedOrigin = (request: Request): void => { - const origin = request.headers.get("origin"); - if (!origin) { - return; - } +export const sanitizeHeaderValue = (value: string): string => + value.replace(/[\r\n]+/g, " ").trim(); - if (!config.allowedOrigins.has(origin.toLowerCase())) { - throw new HttpError(403, "Origin is not allowed."); - } -}; - -export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => { - const headers = new Headers(init); - headers.set("X-Frame-Options", "DENY"); - headers.set("X-Content-Type-Options", "nosniff"); - headers.set("Referrer-Policy", "no-referrer"); - headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); - headers.set( - "Content-Security-Policy", - [ - "default-src 'self'", - "base-uri 'self'", - "frame-ancestors 'none'", - "form-action 'self'", - "object-src 'none'", - "img-src 'self' data: blob:", - "style-src 'self' 'unsafe-inline'", - "font-src 'self' data:", - "connect-src 'self' https:" - ].join("; ") - ); - - if (config.nodeEnv === "production") { - headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload"); - } - - const origin = request.headers.get("origin"); - if (origin && config.allowedOrigins.has(origin.toLowerCase())) { - headers.set("Access-Control-Allow-Origin", origin); - headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email"); - headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); - headers.append("Vary", "Origin"); - } - - return headers; -}; - -export const enforceLimiter = ( - limiter: SlidingWindowLimiter, - request: Request, - scope: string, - max: number, - windowMs: number, - discriminator?: string -): void => { - const ip = getClientIp(request); - const key = `${scope}:${ip}:${discriminator ?? request.method}`; - const result = limiter.consume(key, max, windowMs); - - if (!result.allowed) { - throw new HttpError(429, "Too many requests. Please slow down.", { - details: { retryAfterMs: result.retryAfterMs } - }); - } -}; diff --git a/apps/api/src/lib/validation.ts b/apps/api/src/lib/validation.ts index 5baf084..b84a20d 100644 --- a/apps/api/src/lib/validation.ts +++ b/apps/api/src/lib/validation.ts @@ -1,105 +1,55 @@ import { z } from "zod"; import { config } from "./config"; -import { HttpError } from "./errors"; -const emailRegex = - /^(?=.{1,254}$)(?=.{1,64}@)[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/i; - -export const strictEmailSchema = z +const baseEmailSchema = z .string() .trim() .toLowerCase() - .regex(emailRegex, "Invalid email address.") - .refine((value) => !value.includes(".."), "Invalid email address."); + .email("Enter a valid email address."); -export const hostedMailboxSchema = strictEmailSchema.refine((email) => { - const domain = email.split("@")[1] ?? ""; - return config.hostedDomains.includes(domain); -}, "Mailbox domain is not hosted by this server."); +const hostedDomainSet = new Set(config.hostedDomains); + +export const hostedEmailSchema = baseEmailSchema.superRefine((value, ctx) => { + const domain = value.split("@")[1] ?? ""; + if (!hostedDomainSet.has(domain)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "The email domain is not managed by this Docker Mailserver." + }); + } +}); + +export const passwordSchema = z + .string() + .min(12, "Password must contain at least 12 characters.") + .max(128, "Password is too long."); export const loginSchema = z.object({ - email: strictEmailSchema, - password: z.string().min(8).max(256), - totpCode: z - .string() - .trim() - .regex(/^\d{6}$/) - .optional() + email: baseEmailSchema, + password: z.string().min(1, "Password is required.") }); -export const profileSchema = z.object({ - displayName: z.string().trim().min(1).max(80) +export const createUserSchema = z.object({ + email: hostedEmailSchema }); -export const composeSchema = z.object({ - to: z.array(strictEmailSchema).min(1).max(10), - subject: z.string().trim().max(160), - textBody: z.string().trim().min(1).max(20_000) +export const updatePasswordSchema = z.object({ + password: passwordSchema }); -export const forwardingSchema = z - .object({ - enabled: z.boolean(), - target: strictEmailSchema.nullable() - }) - .superRefine((value, ctx) => { - if (value.enabled && !value.target) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Forwarding target is required." }); - return; - } - }); - -export const totpCodeSchema = z.object({ - code: z.string().trim().regex(/^\d{6}$/) +export const resetTokenSchema = z.object({ + token: z.string().trim().min(32, "Invalid reset token.") }); -export const messageIdSchema = z.object({ - id: z.string().trim().regex(/^[a-f0-9]{16,64}$/) +export const resetPasswordSchema = z.object({ + token: z.string().trim().min(32, "Invalid reset token."), + password: passwordSchema }); -export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => { - if (!target) { - return; - } +export const routeEmailSchema = z.object({ + email: hostedEmailSchema +}); - if (target === mailboxEmail) { - throw new HttpError(400, "Forwarding to the same mailbox is not allowed."); - } - - const domain = target.split("@")[1] ?? ""; - if (!config.forwardingAllowedDomains.includes(domain)) { - throw new HttpError(400, "Forwarding target domain is not allowed by policy."); - } -}; - -type ImageKind = { - ext: string; - mime: string; -}; - -export const detectImageKind = (bytes: Uint8Array): ImageKind | null => { - if (bytes.byteLength < 12) { - return null; - } - - if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { - return { ext: "png", mime: "image/png" }; - } - - if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { - return { ext: "jpg", mime: "image/jpeg" }; - } - - if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { - return { ext: "gif", mime: "image/gif" }; - } - - const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]); - const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]); - if (riff === "RIFF" && webp === "WEBP") { - return { ext: "webp", mime: "image/webp" }; - } - - return null; -}; +export const parseRouteEmail = (value: string): string => + routeEmailSchema.parse({ email: decodeURIComponent(value) }).email; diff --git a/apps/api/src/mail/service.ts b/apps/api/src/mail/service.ts index 2f953f1..03b1634 100644 --- a/apps/api/src/mail/service.ts +++ b/apps/api/src/mail/service.ts @@ -1,201 +1,26 @@ -import { createHash } from "node:crypto"; -import { convert as htmlToText } from "html-to-text"; -import PostalMime from "postal-mime"; -import sanitizeHtml from "sanitize-html"; -import type { MailAttachment, MailMessageDetail, MailMessageSummary } from "@internal-mail/shared"; import { config } from "../lib/config"; -import { HttpError } from "../lib/errors"; -import { strictEmailSchema } from "../lib/validation"; -import { getSupportedFolder, listInboxFiles, readMailboxFile, sendMessage } from "../docker/mailserver"; +import { sanitizeHeaderValue } from "../lib/security"; +import { sendMail } from "../docker/mailserver"; -const parser = new PostalMime(); +export const sendPasswordResetEmail = async (email: string, resetLink: string, expiresAt: string): Promise => { + const subject = sanitizeHeaderValue("Set your mailbox password"); + const from = sanitizeHeaderValue(config.appMailFrom); + const to = sanitizeHeaderValue(email); -const createMessageId = (containerPath: string): string => - createHash("sha256").update(containerPath).digest("hex").slice(0, 32); - -const toText = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); - -const addressFieldToString = (value: unknown): string => { - if (typeof value === "string") { - return value.trim(); - } - - if (Array.isArray(value)) { - return value.map((entry) => addressFieldToString(entry)).filter(Boolean).join(", "); - } - - if (value && typeof value === "object") { - const record = value as Record; - const name = toText(record.name); - const address = toText(record.address); - const text = toText(record.text); - - if (name && address) { - return `${name} <${address}>`; - } - - return text || address; - } - - return ""; -}; - -const addressFieldToList = (value: unknown): string[] => { - if (typeof value === "string") { - return value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - } - - if (Array.isArray(value)) { - return value.map((entry) => addressFieldToString(entry)).filter(Boolean); - } - - if (value && typeof value === "object") { - const asText = addressFieldToString(value); - return asText ? [asText] : []; - } - - return []; -}; - -const sanitizeEmailHtml = (html: string): string => - sanitizeHtml(html, { - allowedTags: ["a", "b", "blockquote", "br", "code", "em", "i", "li", "ol", "p", "pre", "strong", "u", "ul"], - allowedAttributes: { - a: ["href", "title", "target", "rel"] - }, - allowedSchemes: ["https", "mailto"], - transformTags: { - a: (_tagName: string, attribs: Record) => ({ - tagName: "a", - attribs: { - href: attribs.href ?? "#", - title: attribs.title ?? "", - target: "_blank", - rel: "noopener noreferrer nofollow" - } - }) - } - }); - -const buildPreview = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, 160); - -const toIsoString = (value: unknown): string => { - if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString(); - } - - if (typeof value === "string") { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - - return new Date().toISOString(); -}; - -const normalizeAttachments = (value: unknown): MailAttachment[] => { - if (!Array.isArray(value)) { - return []; - } - - return value.map((entry) => { - const record = entry as Record; - return { - filename: toText(record.filename) || "attachment", - mimeType: toText(record.mimeType) || "application/octet-stream", - size: typeof record.size === "number" ? record.size : null - }; - }); -}; - -const parseMessage = async (containerPath: string, raw: string): Promise => { - const parsed = (await parser.parse(raw)) as Record; - const html = typeof parsed.html === "string" ? parsed.html : null; - const safeHtml = html ? sanitizeEmailHtml(html) : null; - const textBody = - toText(parsed.text) || - (safeHtml - ? htmlToText(safeHtml, { - wordwrap: 120, - selectors: [{ selector: "a", options: { ignoreHref: false } }] - }).trim() - : ""); - - return { - id: createMessageId(containerPath), - from: addressFieldToString(parsed.from) || "Unknown sender", - to: addressFieldToList(parsed.to), - subject: toText(parsed.subject) || "(No subject)", - preview: buildPreview(textBody), - receivedAt: toIsoString(parsed.date), - textBody, - safeHtml, - hasHtml: Boolean(safeHtml), - attachments: normalizeAttachments(parsed.attachments) - }; -}; - -export const listMessagesForMailbox = async (email: string): Promise => { - const files = await listInboxFiles(email); - const items = await Promise.all( - files.slice(0, config.maxMessages).map(async (path) => { - const raw = await readMailboxFile(path); - const parsed = await parseMessage(path, raw); - const summary: MailMessageSummary = { - id: parsed.id, - from: parsed.from, - subject: parsed.subject, - preview: parsed.preview, - receivedAt: parsed.receivedAt, - hasHtml: parsed.hasHtml - }; - return summary; - }) - ); - - return items.sort((left, right) => right.receivedAt.localeCompare(left.receivedAt)); -}; - -export const getMessageForMailbox = async (email: string, id: string): Promise => { - const files = await listInboxFiles(email); - const match = files.find((path) => createMessageId(path) === id); - if (!match) { - throw new HttpError(404, "Message not found."); - } - - const raw = await readMailboxFile(match); - return parseMessage(match, raw); -}; - -export const composeAndSendMessage = async ( - sender: string, - recipients: string[], - subject: string, - textBody: string -): Promise => { - const cleanRecipients = recipients.map((email) => strictEmailSchema.parse(email)); - const normalizedSender = strictEmailSchema.parse(sender); - const cleanSubject = subject.replace(/[\r\n]+/g, " ").trim(); - const cleanText = textBody.replace(/\r\n/g, "\n").trim(); - - const rawMessage = [ - `From: ${normalizedSender}`, - `To: ${cleanRecipients.join(", ")}`, - `Subject: ${cleanSubject || "(No subject)"}`, - `Date: ${new Date().toUTCString()}`, - `Message-ID: <${crypto.randomUUID()}@${normalizedSender.split("@")[1]}>`, - "MIME-Version: 1.0", - "Content-Type: text/plain; charset=UTF-8", - "Content-Transfer-Encoding: 8bit", + const message = [ + `From: Mail Admin <${from}>`, + `To: <${to}>`, + `Subject: ${subject}`, + "Content-Type: text/plain; charset=utf-8", "", - cleanText - ].join("\r\n"); + "A mailbox password reset was requested for your account.", + "", + `Use this one-time link to choose a new password: ${resetLink}`, + `This link expires at ${expiresAt}.`, + "", + "If you did not expect this message, contact your administrator." + ].join("\n"); - await sendMessage(normalizedSender, cleanRecipients, rawMessage); + await sendMail(message); }; -export const getMailboxFolderLabel = (): string => getSupportedFolder(); diff --git a/apps/api/src/settings/service.ts b/apps/api/src/settings/service.ts deleted file mode 100644 index e15bb85..0000000 --- a/apps/api/src/settings/service.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { join } from "node:path"; -import type { ForwardingRule, ProfileSettings } from "@internal-mail/shared"; -import { config } from "../lib/config"; -import { - deleteAvatarFile, - ensureProfile, - getForwardingRule, - getProfile, - setAvatarPath, - updateProfile, - upsertForwardingRule -} from "../lib/db"; -import { HttpError } from "../lib/errors"; -import { randomToken } from "../lib/crypto"; -import { detectImageKind, ensureForwardingTargetAllowed } from "../lib/validation"; -import { addForwardingAlias, removeForwardingAlias } from "../docker/mailserver"; - -const toAvatarUrl = (email: string, avatarPath: string | null): string | null => - avatarPath ? `/api/settings/avatar/${encodeURIComponent(email)}` : null; - -export const getProfileSettings = (email: string): ProfileSettings => { - const profile = ensureProfile(email); - return { - email: profile.email, - displayName: profile.display_name, - role: profile.role, - avatarUrl: toAvatarUrl(profile.email, profile.avatar_path), - totpEnabled: Boolean(profile.totp_enabled) - }; -}; - -export const updateDisplayName = (email: string, displayName: string): ProfileSettings => { - const profile = updateProfile(email, displayName); - return { - email: profile.email, - displayName: profile.display_name, - role: profile.role, - avatarUrl: toAvatarUrl(profile.email, profile.avatar_path), - totpEnabled: Boolean(profile.totp_enabled) - }; -}; - -export const saveAvatar = async (email: string, file: File): Promise => { - if (file.size > config.avatarMaxBytes) { - throw new HttpError(413, "Avatar exceeds the configured size limit."); - } - - const bytes = new Uint8Array(await file.arrayBuffer()); - const imageKind = detectImageKind(bytes); - if (!imageKind) { - throw new HttpError(400, "Only PNG, JPEG, GIF, and WebP avatars are allowed."); - } - - const profile = getProfile(email) ?? ensureProfile(email); - const nextFilename = `${randomToken(18)}.${imageKind.ext}`; - const absolutePath = join(config.avatarDir, nextFilename); - await Bun.write(absolutePath, bytes); - await deleteAvatarFile(profile.avatar_path); - - const updated = setAvatarPath(email, absolutePath); - return { - email: updated.email, - displayName: updated.display_name, - role: updated.role, - avatarUrl: toAvatarUrl(updated.email, updated.avatar_path), - totpEnabled: Boolean(updated.totp_enabled) - }; -}; - -export const getAvatarFile = (email: string): Bun.BunFile => { - const profile = getProfile(email); - if (!profile?.avatar_path) { - throw new HttpError(404, "Avatar not found."); - } - - return Bun.file(profile.avatar_path); -}; - -export const getForwardingSettings = (email: string): ForwardingRule => { - const current = getForwardingRule(email); - return { - enabled: Boolean(current?.enabled), - target: current?.target_email ?? null, - allowedDomains: config.forwardingAllowedDomains - }; -}; - -export const updateForwarding = async (email: string, enabled: boolean, target: string | null): Promise => { - ensureForwardingTargetAllowed(email, target); - const previous = getForwardingRule(email); - - if (previous?.enabled && previous.target_email) { - await removeForwardingAlias(email, previous.target_email); - } - - if (enabled && target) { - await addForwardingAlias(email, target); - } - - const next = upsertForwardingRule(email, enabled ? target : null, enabled); - return { - enabled: Boolean(next.enabled), - target: next.target_email ?? null, - allowedDomains: config.forwardingAllowedDomains - }; -}; - diff --git a/apps/api/src/types/external-modules.d.ts b/apps/api/src/types/external-modules.d.ts deleted file mode 100644 index ef9e3c7..0000000 --- a/apps/api/src/types/external-modules.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -declare module "sanitize-html" { - type SanitizeOptions = { - allowedTags?: string[]; - allowedAttributes?: Record; - allowedSchemes?: string[]; - transformTags?: Record< - string, - (tagName: string, attribs: Record) => { - tagName: string; - attribs: Record; - } - >; - }; - - export default function sanitizeHtml(value: string, options?: SanitizeOptions): string; -} - -declare module "html-to-text" { - export function convert( - html: string, - options?: { - wordwrap?: number; - selectors?: Array<{ - selector: string; - options?: Record; - }>; - } - ): string; -} diff --git a/apps/api/src/users/service.ts b/apps/api/src/users/service.ts new file mode 100644 index 0000000..f5d79d4 --- /dev/null +++ b/apps/api/src/users/service.ts @@ -0,0 +1,143 @@ +import type { CreateUserResponse, MailUser, PasswordResetInspectResponse, PasswordResetTriggerResponse } from "@internal-mail/shared"; +import { + addMailUser, + deleteMailUser, + listMailUsers, + updateMailUserPassword +} from "../docker/mailserver"; +import { config } from "../lib/config"; +import { + claimPasswordResetToken, + createPasswordResetToken, + deleteActivePasswordResetTokensForUser, + deletePasswordResetToken, + getActivePasswordResetToken, + releasePasswordResetToken +} from "../lib/db"; +import { HttpError } from "../lib/errors"; +import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security"; +import { sendPasswordResetEmail } from "../mail/service"; + +const maskEmail = (email: string): string => { + const [rawLocalPart, rawDomain] = email.split("@"); + const localPart = rawLocalPart ?? email; + const domain = rawDomain ?? ""; + const maskedLocal = localPart.length <= 2 + ? `${localPart[0] ?? "*"}*` + : `${localPart.slice(0, 2)}${"*".repeat(Math.max(1, localPart.length - 2))}`; + + return `${maskedLocal}@${domain}`; +}; + +const buildResetLink = (token: string): string => + `${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`; + +const ensureUserExists = async (email: string): Promise => { + const users = await listMailUsers(); + const existing = users.find((item) => item.email === email); + if (!existing) { + throw new HttpError(404, "Mailbox user not found."); + } + + return existing; +}; + +export const listUsers = async (): Promise => + listMailUsers(); + +export const createUser = async (email: string, createdBy: string): Promise => { + const temporaryPassword = createTemporaryPassword(); + await addMailUser(email, temporaryPassword); + + const user = await ensureUserExists(email); + + try { + const reset = await triggerPasswordReset(email, createdBy); + + 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." + }; + } +}; + +export const updateUserPassword = async (email: string, password: string): Promise => { + await ensureUserExists(email); + await updateMailUserPassword(email, password); +}; + +export const deleteUser = async (email: string): Promise => { + await ensureUserExists(email); + await deleteMailUser(email); +}; + +export const triggerPasswordReset = async ( + email: string, + createdBy: string +): Promise => { + await ensureUserExists(email); + deleteActivePasswordResetTokensForUser(email); + + const rawToken = createOpaqueToken(); + const tokenHash = hashToken(rawToken); + const expiresAtMs = Date.now() + config.resetTokenTtlMs; + const expiresAt = new Date(expiresAtMs).toISOString(); + + createPasswordResetToken({ + tokenHash, + userEmail: email, + createdBy, + expiresAt: expiresAtMs + }); + + try { + await sendPasswordResetEmail(email, buildResetLink(rawToken), expiresAt); + } catch (error) { + deletePasswordResetToken(tokenHash); + throw error; + } + + return { expiresAt }; +}; + +export const inspectPasswordReset = async (token: string): Promise => { + const resetToken = getActivePasswordResetToken(hashToken(token)); + if (!resetToken) { + throw new HttpError(400, "This password reset link is invalid or expired."); + } + + return { + emailHint: maskEmail(resetToken.user_email), + expiresAt: new Date(resetToken.expires_at).toISOString() + }; +}; + +export const consumePasswordReset = async (token: string, password: string): Promise => { + const tokenHash = hashToken(token); + const resetToken = getActivePasswordResetToken(tokenHash); + + if (!resetToken) { + throw new HttpError(400, "This password reset link is invalid or expired."); + } + + const claimed = claimPasswordResetToken(tokenHash); + if (!claimed) { + throw new HttpError(400, "This password reset link is invalid or already used."); + } + + try { + await updateMailUserPassword(resetToken.user_email, password); + } catch (error) { + releasePasswordResetToken(tokenHash); + throw error; + } +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7633f69..a0b4351 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,13 +1,6 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": [ - "ES2022", - "DOM" - ] - }, "include": [ "src" ] } - diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 57f1720..f1f1ab8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "jsx": "react-jsx", - "rootDir": "src" + "jsx": "react-jsx" }, "include": [ "src", diff --git a/bun.lock b/bun.lock index 82446f3..efdecee 100644 --- a/bun.lock +++ b/bun.lock @@ -10,15 +10,11 @@ "version": "0.0.1", "dependencies": { "@internal-mail/shared": "workspace:*", - "elysia": "^1.3.0", - "html-to-text": "^9.0.5", - "otpauth": "^9.4.1", - "postal-mime": "^2.4.4", - "sanitize-html": "^2.17.0", - "zod": "^3.24.2", + "elysia": "^1.3.10", + "zod": "^3.25.0", }, "devDependencies": { - "bun-types": "^1.3.11", + "bun-types": "^1.3.10", "typescript": "^5.8.3", }, }, @@ -27,23 +23,23 @@ "version": "0.0.1", "dependencies": { "@internal-mail/shared": "workspace:*", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", }, "devDependencies": { "@types/react": "^19.1.3", "@types/react-dom": "^19.1.3", - "@vitejs/plugin-react": "^4.4.1", - "bun-types": "^1.3.11", + "@vitejs/plugin-react": "^5.0.0", + "bun-types": "^1.3.10", "typescript": "^5.8.3", - "vite": "^6.2.4", + "vite": "^7.0.0", }, }, "packages/shared": { "name": "@internal-mail/shared", "version": "0.0.1", "devDependencies": { - "bun-types": "^1.3.11", + "bun-types": "^1.3.10", "typescript": "^5.8.3", }, }, @@ -89,57 +85,57 @@ "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], "@internal-mail/api": ["@internal-mail/api@workspace:apps/api"], @@ -157,61 +153,57 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="], - - "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], @@ -235,7 +227,7 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "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": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], @@ -243,7 +235,7 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -253,28 +245,14 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], - - "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - - "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - - "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.328", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="], "elysia": ["elysia@1.4.28", "", { "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=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.27.4", "", { "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": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], @@ -287,22 +265,14 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], - - "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], @@ -315,36 +285,22 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "otpauth": ["otpauth@9.5.0", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA=="], - - "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], - - "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], - - "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], - - "sanitize-html": ["sanitize-html@2.17.2", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg=="], + "rollup": ["rollup@4.60.1", "", { "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": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -363,14 +319,10 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "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": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "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-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "vite": ["vite@7.3.1", "", { "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": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "sanitize-html/htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - - "sanitize-html/htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], } } diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf index 621bd9f..c4b4acd 100644 --- a/infra/nginx/default.conf +++ b/infra/nginx/default.conf @@ -1,41 +1,18 @@ server { - listen 443 ssl http2; - server_name mail.example.com; + listen 80; + server_name _; - ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - ssl_session_timeout 1d; - ssl_session_cache shared:SSL:10m; - - client_max_body_size 3m; - - add_header X-Frame-Options "DENY" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "no-referrer" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self';" always; - - proxy_http_version 1.1; + location / { + proxy_pass http://frontend:5173; 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-Proto https; - proxy_set_header Connection ""; + proxy_set_header X-Forwarded-Proto $scheme; + } - location /api/ { - proxy_pass http://api:3001/api/; - } - - location / { - proxy_pass http://web:5173/; - } -} - -server { - listen 80; - server_name mail.example.com; - return 301 https://$host$request_uri; + location /api/ { + proxy_pass http://api:3001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } diff --git a/package.json b/package.json index e884bef..e84adef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "internal-mail-server", "private": true, - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.10", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/shared/package.json b/packages/shared/package.json index 3570b1b..80968b4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -10,8 +10,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { - "bun-types": "^1.3.11", + "bun-types": "^1.3.10", "typescript": "^5.8.3" } } - diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 501e19f..7b48cc1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,60 +1,63 @@ -export type UserRole = "admin" | "user"; - -export interface SessionUser { - email: string; - displayName: string; - role: UserRole; - avatarUrl: string | null; - totpEnabled: boolean; -} - -export interface SessionView { +export type AdminSession = { authenticated: boolean; - csrfToken: string | null; - user: SessionUser | null; -} + adminEmail: string | null; +}; -export interface ForwardingRule { - enabled: boolean; - target: string | null; - allowedDomains: string[]; -} +export type SessionResponse = { + session: AdminSession; +}; -export interface ProfileSettings { +export type MailUser = { email: string; - displayName: string; - role: UserRole; - avatarUrl: string | null; - totpEnabled: boolean; -} + localPart: string; + domain: string; +}; -export interface MailAttachment { - filename: string; - mimeType: string; - size: number | null; -} +export type UsersResponse = { + items: MailUser[]; +}; -export interface MailMessageSummary { - id: string; - from: string; - subject: string; - preview: string; - receivedAt: string; - hasHtml: boolean; -} +export type CreateUserRequest = { + email: string; +}; -export interface MailMessageDetail extends MailMessageSummary { - to: string[]; - textBody: string; - safeHtml: string | null; - attachments: MailAttachment[]; -} +export type CreateUserResponse = { + user: MailUser; + resetLinkSent: boolean; + resetLinkExpiresAt: string | null; + warning: string | null; +}; -export interface LoginResponse { - session: SessionView; -} +export type UpdateUserPasswordRequest = { + password: string; +}; + +export type LoginRequest = { + email: string; + password: string; +}; + +export type PasswordResetTriggerResponse = { + expiresAt: string; +}; + +export type PasswordResetInspectRequest = { + token: string; +}; + +export type PasswordResetInspectResponse = { + emailHint: string; + expiresAt: string; +}; + +export type PasswordResetConsumeRequest = { + token: string; + password: string; +}; + +export type ApiErrorBody = { + error: { + message: string; + }; +}; -export interface TotpSetupResponse { - secret: string; - otpauthUrl: string; -} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 3c1333f..f49193d 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -2,11 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "declaration": true, - "emitDeclarationOnly": true + "rootDir": "src", + "noEmit": false }, "include": [ "src" ] } - diff --git a/tsconfig.base.json b/tsconfig.base.json index 7a3689c..4d262b3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,7 +7,6 @@ "noEmit": true, "skipLibCheck": true, "verbatimModuleSyntax": true, - "allowImportingTsExtensions": true, "resolveJsonModule": true, "useDefineForClassFields": true, "types": [ @@ -21,4 +20,3 @@ } } } -