re-vibecode

This commit is contained in:
Debian
2026-03-30 13:57:39 +00:00
parent 09d76c044b
commit bb8497d33d
29 changed files with 1040 additions and 1622 deletions
+7 -14
View File
@@ -2,22 +2,15 @@ NODE_ENV=development
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=3001 APP_PORT=3001
APP_ORIGINS=http://localhost:5173 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 MAILSERVER_CONTAINER=mailserver
DOCKER_BINARY=docker
MAILSERVER_TIMEOUT_MS=10000 MAILSERVER_TIMEOUT_MS=10000
HOSTED_DOMAINS=example.com HOSTED_DOMAINS=example.com
FORWARDING_ALLOWED_DOMAINS=example.com SESSION_COOKIE_NAME=mail_admin_session
SESSION_TTL_MINUTES=15 SESSION_TTL_MINUTES=480
GLOBAL_RATE_LIMIT=240 RESET_TOKEN_TTL_MINUTES=60
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
APP_ADMIN_EMAIL=[email protected] APP_ADMIN_EMAIL=[email protected]
APP_ADMIN_PASSWORD=change-me-now APP_ADMIN_PASSWORD=change-me-now
# 32-byte base64 key used to encrypt TOTP secrets at rest. APP_MAIL_FROM=[email protected]
TOTP_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY
+2 -6
View File
@@ -5,10 +5,6 @@ build
.env .env
.env.local .env.local
data/app.db data/app.db
data/avatars/* data/app.db-shm
!data/avatars/.gitkeep data/app.db-wal
coverage coverage
apps/api/data/app.db
apps/api/data/app.db-shm
apps/api/data/app.db-wal
+3
View File
@@ -0,0 +1,3 @@
{
"discord.enabled": false
}
+155 -138
View File
@@ -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 ### Backend
- 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
## 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 | - Bun keeps the runtime, package manager, and SQLite support simple.
| --- | --- | - ElysiaJS is a good fit for a small internal API without extra framework weight.
| 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. | - SQLite is enough for admin sessions and password reset tokens, and keeps deployment straightforward.
| Session theft or token leakage | Server-side opaque sessions in `httpOnly`, `SameSite=Strict` cookies. The frontend never stores session identifiers in `localStorage`. | - Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
| 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. |
### 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` - It is easy for most full-stack teams to read and maintain.
- Optional seeded local admin account with Argon2id-hashed password - This panel only needs basic local state and forms, so React works well without routers or global state libraries.
- Inbox listing and message preview from Maildir inside the mail container - The UI stays minimal while still being easy to grow later.
- 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
## Project layout ## 2. Folder structure
```text ```text
apps/ apps/
api/ Elysia backend api/
web/ React/Vite frontend src/
auth/
docker/
lib/
mail/
users/
web/
src/
api/
components/
pages/
styles/
packages/ packages/
shared/ Shared TypeScript types shared/
infra/
nginx/ Reverse proxy example with security headers
data/ 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 ### API design
- `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
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 <email> <password>`
- `setup email update <email> <password>`
- `setup email del <email>`
- `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 ```bash
openssl rand -base64 32 bun install
``` ```
### 2. Install dependencies 4. Start the API:
If Bun needs an explicit temp dir on your machine:
```bash ```bash
BUN_TMPDIR=/tmp bun install bun run dev:api
``` ```
### 3. Start the apps 5. In another terminal, start the frontend:
Backend:
```bash ```bash
bun run --cwd apps/api dev bun run dev:web
``` ```
Frontend: 6. Open `http://localhost:5173`.
```bash ## Notes and assumptions
bun run --cwd apps/web dev
```
The Vite dev server proxies `/api` to `http://localhost:3001`. - 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.
Important en local: - The app stores only panel-specific metadata in SQLite. Mailbox accounts themselves remain managed by Docker Mailserver.
- 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/<domain>/<local-part>/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.
+3 -7
View File
@@ -9,15 +9,11 @@
}, },
"dependencies": { "dependencies": {
"@internal-mail/shared": "workspace:*", "@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0", "elysia": "^1.3.10",
"html-to-text": "^9.0.5", "zod": "^3.25.0"
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }
+64 -138
View File
@@ -1,174 +1,100 @@
import * as OTPAuth from "otpauth"; import type { AdminSession } from "@internal-mail/shared";
import type { SessionView, UserRole } from "@internal-mail/shared";
import { authenticateMailbox } from "../docker/mailserver";
import { config } from "../lib/config"; import { config } from "../lib/config";
import { decryptAtRest, encryptAtRest } from "../lib/crypto"; import { createSession, deleteSession, getAdmin, getSession, upsertAdmin } from "../lib/db";
import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db";
import { HttpError } from "../lib/errors"; 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 => const sessionView = (adminEmail: string | null): AdminSession => ({
new OTPAuth.TOTP({ authenticated: Boolean(adminEmail),
issuer, adminEmail
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)
}
};
};
export const seedAdminAccount = async (): Promise<void> => { export const seedAdminAccount = async (): Promise<void> => {
if (!config.adminEmail || !config.adminPassword) { if (!config.appAdminEmail || !config.appAdminPassword) {
return; return;
} }
const existing = getAdmin(config.adminEmail); const existing = getAdmin(config.appAdminEmail);
if (existing && (await Bun.password.verify(config.adminPassword, existing.password_hash))) { if (existing && (await Bun.password.verify(config.appAdminPassword, existing.password_hash))) {
ensureProfile(config.adminEmail, "admin");
return; return;
} }
const passwordHash = await Bun.password.hash(config.adminPassword, { const passwordHash = await Bun.password.hash(config.appAdminPassword, {
algorithm: "argon2id", algorithm: "argon2id",
memoryCost: 19456, memoryCost: 19_456,
timeCost: 2 timeCost: 2
}); });
upsertAdmin(config.adminEmail, passwordHash); upsertAdmin(config.appAdminEmail, passwordHash);
}; };
export const login = async ( export const loginAdmin = async (email: string, password: string): Promise<{
email: string, session: AdminSession;
password: string, cookie: string;
totpCode: string | undefined, }> => {
_request: Request const admin = getAdmin(email);
): Promise<{ session: SessionView }> => { if (!admin) {
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)
};
}
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."); throw new HttpError(401, "Invalid email or password.");
} }
role = "admin";
} else { const validPassword = await Bun.password.verify(password, admin.password_hash);
hostedMailboxSchema.parse(normalizedEmail); if (!validPassword) {
const isValid = await authenticateMailbox(normalizedEmail, password);
if (!isValid) {
throw new HttpError(401, "Invalid email or password."); throw new HttpError(401, "Invalid email or password.");
} }
const rawToken = createOpaqueToken();
const tokenHash = hashToken(rawToken);
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(tokenHash, admin.email, expiresAt);
return {
session: sessionView(admin.email),
cookie: buildCookieValue(config.sessionCookieName, rawToken, {
maxAgeSeconds: cookieMaxAgeSeconds,
secure: config.isProduction
})
};
};
export const logoutAdmin = (request: Request): string => {
const rawToken = readCookie(request, config.sessionCookieName);
if (rawToken) {
deleteSession(hashToken(rawToken));
} }
const profile = ensureProfile(normalizedEmail, role); return buildClearedCookieValue(config.sessionCookieName, config.isProduction);
if (profile.totp_enabled) { };
if (!totpCode) {
throw new HttpError(401, "Two-factor code is required."); export const requireAdminSession = (request: Request): { adminEmail: string } => {
const rawToken = readCookie(request, config.sessionCookieName);
if (!rawToken) {
throw new HttpError(401, "Authentication required.");
} }
const decryptedSecret = await decryptAtRest(profile.totp_secret!); const session = getSession(hashToken(rawToken));
const delta = createTotp(normalizedEmail, decryptedSecret).validate({ token: totpCode, window: 1 }); if (!session || session.expires_at <= Date.now()) {
if (delta === null) { throw new HttpError(401, "Authentication required.");
throw new HttpError(401, "Invalid two-factor code.");
}
} }
return { return {
session: buildSessionView(normalizedEmail, role, null) adminEmail: session.admin_email
}; };
}; };
export const getSessionFromRequest = async (request: Request) => { export const getSessionFromRequest = (request: Request): AdminSession => {
const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail; try {
if (!headerEmail) { return sessionView(requireAdminSession(request).adminEmail);
throw new HttpError(401, "X-User-Email header is required."); } catch {
return sessionView(null);
} }
const email = strictEmailSchema.parse(headerEmail);
const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user";
ensureProfile(email, role);
return {
id: "stateless",
email,
role,
csrfToken: null,
session: buildSessionView(email, role, null)
};
}; };
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 enableTotp = async (email: string, code: string): Promise<void> => {
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<void> => {
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);
};
+27 -88
View File
@@ -1,104 +1,43 @@
import { config } from "../lib/config"; import type { MailUser } from "@internal-mail/shared";
import { HttpError } from "../lib/errors";
import { hostedMailboxSchema } from "../lib/validation";
import { runDocker } from "./runner"; import { runDocker } from "./runner";
const MAILDIR_FOLDER = "INBOX"; const emailPattern = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
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 looksLikeInfrastructureFailure = (output: string): boolean => const toMailUser = (email: string): MailUser => {
infrastructureErrorPatterns.some((pattern) => pattern.test(output)); 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 } => { export const listMailUsers = async (): Promise<MailUser[]> => {
const normalized = hostedMailboxSchema.parse(email); const { stdout } = await runDocker(["setup", "email", "list"]);
const [localPart, domain] = normalized.split("@"); const emails = new Set<string>();
if (!localPart || !domain || !localPartRegex.test(localPart)) {
throw new HttpError(400, "Unsupported mailbox format."); 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 => { export const addMailUser = async (email: string, password: string): Promise<void> => {
const { localPart, domain } = splitHostedEmail(email); await runDocker(["setup", "email", "add", email, password]);
return `/var/mail/${domain}/${localPart}/Maildir`;
}; };
export const authenticateMailbox = async (email: string, password: string): Promise<boolean> => { export const updateMailUserPassword = async (email: string, password: string): Promise<void> => {
const normalizedEmail = hostedMailboxSchema.parse(email); await runDocker(["setup", "email", "update", email, password]);
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 listInboxFiles = async (email: string): Promise<string[]> => { export const deleteMailUser = async (email: string): Promise<void> => {
const root = buildMaildirRoot(email); await runDocker(["setup", "email", "del", 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 readMailboxFile = async (containerPath: string): Promise<string> => { export const sendMail = async (rawMessage: string): Promise<void> => {
if (!containerPath.startsWith("/var/mail/")) { await runDocker(["sendmail", "-t", "-i"], rawMessage);
throw new HttpError(400, "Invalid mail file path.");
}
const result = await runDocker(["exec", config.mailserverContainer, "cat", containerPath]);
return result.stdout;
}; };
export const sendMessage = async (sender: string, recipients: string[], rawMessage: string): Promise<void> => {
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<void> => {
await runDocker(["exec", config.mailserverContainer, "setup", "alias", "add", sourceEmail, targetEmail]);
};
export const removeForwardingAlias = async (sourceEmail: string, targetEmail: string): Promise<void> => {
await runDocker(["exec", config.mailserverContainer, "setup", "alias", "del", sourceEmail, targetEmail], {
allowNonZero: true
});
};
export const getSupportedFolder = (): string => MAILDIR_FOLDER;
+49 -46
View File
@@ -1,67 +1,70 @@
import { spawn } from "node:child_process";
import { config } from "../lib/config"; import { config } from "../lib/config";
import { HttpError } from "../lib/errors"; import { HttpError } from "../lib/errors";
type DockerRunOptions = { type DockerResult = {
stdinText?: string;
allowNonZero?: boolean;
};
export type DockerRunResult = {
stdout: string; stdout: string;
stderr: string; stderr: string;
exitCode: number;
}; };
export class DockerCommandError extends Error { export const runDocker = async (
readonly exitCode: number; args: string[],
readonly stderr: string; input?: string
): Promise<DockerResult> => {
constructor(message: string, exitCode: number, stderr: string) { const controller = new AbortController();
super(message); const timeout = setTimeout(() => controller.abort(), config.mailserverTimeoutMs);
this.name = "DockerCommandError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
export const runDocker = async (args: string[], options?: DockerRunOptions): Promise<DockerRunResult> => {
const process = Bun.spawn({
cmd: ["docker", ...args],
stdout: "pipe",
stderr: "pipe",
stdin: options?.stdinText ? "pipe" : "ignore",
env: {}
});
const timeout = setTimeout(() => {
process.kill();
}, config.mailserverTimeoutMs);
try { try {
if (options?.stdinText && process.stdin) { const child = spawn(
await process.stdin.write(new TextEncoder().encode(options.stdinText)); config.dockerBinary,
await process.stdin.end(); ["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([ child.stdin.end();
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
if (exitCode !== 0 && !options?.allowNonZero) { const exitCode = await new Promise<number>((resolve, reject) => {
throw new DockerCommandError("Docker command failed.", exitCode, stderr); 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) { } catch (error) {
if (error instanceof DockerCommandError) { if (error instanceof HttpError) {
throw error; throw error;
} }
throw new HttpError(504, "Docker command timed out or failed to execute.", { if (error instanceof Error && error.name === "AbortError") {
expose: false throw new HttpError(504, "Docker Mailserver command timed out.");
}); }
throw new HttpError(502, "Docker Mailserver is unreachable.");
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
+101 -145
View File
@@ -1,21 +1,43 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service"; import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, seedAdminAccount } from "./auth/service";
import { HttpError } from "./lib/errors"; import { cleanupDatabase } from "./lib/db";
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http"; import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config"; import { config } from "./lib/config";
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security"; import {
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation"; createUserSchema,
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service"; loginSchema,
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service"; parseRouteEmail,
resetPasswordSchema,
resetTokenSchema,
updatePasswordSchema
} from "./lib/validation";
import {
consumePasswordReset,
createUser,
deleteUser,
inspectPasswordReset,
listUsers,
triggerPasswordReset,
updateUserPassword
} from "./users/service";
const app = new Elysia(); const app = new Elysia();
const route = (handler: (request: Request, params?: Record<string, string>) => Promise<Response> | Response) => const route = (
handler: (context: {
request: Request;
params: Record<string, string>;
}) => Promise<Response> | Response
) =>
async ({ request, params }: { request: Request; params: Record<string, string> }) => { async ({ request, params }: { request: Request; params: Record<string, string> }) => {
if (request.method === "OPTIONS") {
return optionsResponse(request);
}
try { try {
assertAllowedOrigin(request); assertAllowedOrigin(request);
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs); cleanupDatabase();
return await handler(request, params); return await handler({ request, params });
} catch (error) { } catch (error) {
return errorResponse(request, error); return errorResponse(request, error);
} }
@@ -23,179 +45,113 @@ const route = (handler: (request: Request, params?: Record<string, string>) => P
await seedAdminAccount(); await seedAdminAccount();
app.onRequest(({ request }) => {
if (request.method === "OPTIONS") {
return emptyResponse(request, 204);
}
});
app.get( app.get(
"/api/health", "/api/health",
route(async (request) => route(async ({ request }) =>
jsonResponse(request, 200, { jsonResponse(request, 200, {
ok: true, ok: true
mode: config.nodeEnv,
folder: getMailboxFolderLabel()
}) })
) )
); );
app.get( app.get(
"/api/auth/session", "/api/auth/session",
route(async (request) => { route(async ({ request }) =>
try { jsonResponse(request, 200, {
const session = await getSessionFromRequest(request); session: getSessionFromRequest(request)
return jsonResponse(request, 200, { session: session.session });
} catch {
return jsonResponse(request, 200, { session: getAnonymousSession() });
}
}) })
)
); );
app.post( app.post(
"/api/auth/login", "/api/auth/login",
route(async (request) => { route(async ({ request }) => {
if (!config.allowInsecureLogin) {
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
}
const body = await parseJson(request, loginSchema); 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( return jsonResponse(
request, request,
200, 200,
{ session: result.session } { session: result.session },
{ "set-cookie": result.cookie }
); );
}) })
); );
app.post( app.post(
"/api/auth/logout", "/api/auth/logout",
route(async (request) => { route(async ({ request }) =>
return jsonResponse(request, 200, { session: getAnonymousSession() }); jsonResponse(
}) request,
); 200,
{ session: { authenticated: false, adminEmail: null } },
app.post( { "set-cookie": logoutAdmin(request) }
"/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);
})
); );
app.get( app.get(
"/api/settings/profile", "/api/users",
route(async (request) => { route(async ({ request }) => {
const session = await getSessionFromRequest(request); requireAdminSession(request);
return jsonResponse(request, 200, getProfileSettings(session.email)); 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( app.patch(
"/api/settings/profile", "/api/users/:email/password",
route(async (request) => { route(async ({ request, params }) => {
const session = await getSessionFromRequest(request); requireAdminSession(request);
const body = await parseJson(request, profileSchema); const email = parseRouteEmail(params.email);
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName)); 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( app.post(
"/api/settings/avatar", "/api/users/:email/password-reset",
route(async (request) => { route(async ({ request, params }) => {
const session = await getSessionFromRequest(request); const session = requireAdminSession(request);
const formData = await request.formData(); const email = parseRouteEmail(params.email);
const avatar = formData.get("avatar"); const result = await triggerPasswordReset(email, session.adminEmail);
if (!(avatar instanceof File)) { return jsonResponse(request, 200, result);
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);
}) })
); );
app.post( app.post(
"/api/mail/send", "/api/password-resets/inspect",
route(async (request) => { route(async ({ request }) => {
const session = await getSessionFromRequest(request); const body = await parseJson(request, resetTokenSchema);
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email); return jsonResponse(request, 200, await inspectPasswordReset(body.token));
const body = await parseJson(request, composeSchema); })
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody); );
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); return emptyResponse(request, 204);
}) })
); );
@@ -205,4 +161,4 @@ app.listen({
port: config.port 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}`);
+29 -66
View File
@@ -1,73 +1,36 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { z } from "zod";
const normalizedEnv = Object.fromEntries( const env = Bun.env;
Object.entries(Bun.env).map(([key, value]) => [key, value === "" ? undefined : value])
);
const splitList = (value: string | undefined, fallback: string): string[] => const parseList = (value: string | undefined, fallback: string[]): string[] =>
(value ?? fallback) (value ?? "")
.split(",") .split(",")
.map((entry) => entry.trim().toLowerCase()) .map((item) => item.trim().toLowerCase())
.filter(Boolean); .filter(Boolean)
.concat(fallback)
.filter((item, index, items) => items.indexOf(item) === index);
const envSchema = z.object({ const parseNumber = (value: string | undefined, fallback: number): number => {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), const parsed = Number(value);
APP_HOST: z.string().default("0.0.0.0"), return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
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.");
}
export const config = { export const config = {
nodeEnv: raw.NODE_ENV, nodeEnv: env.NODE_ENV ?? "development",
host: raw.APP_HOST, host: env.APP_HOST ?? "0.0.0.0",
port: raw.APP_PORT, port: parseNumber(env.APP_PORT, 3001),
externalBaseUrl: raw.APP_EXTERNAL_BASE_URL, appOrigins: parseList(env.APP_ORIGINS, ["http://localhost:5173"]),
allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")), externalBaseUrl: (env.APP_EXTERNAL_BASE_URL ?? "http://localhost:5173").replace(/\/+$/, ""),
dataDir: resolve(process.cwd(), raw.DATA_DIR), dbPath: resolve(process.cwd(), env.APP_DATA_PATH ?? "../../data/app.db"),
avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"), mailserverContainer: env.MAILSERVER_CONTAINER ?? "mailserver",
dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"), dockerBinary: env.DOCKER_BINARY ?? "docker",
mailserverContainer: raw.MAILSERVER_CONTAINER, mailserverTimeoutMs: parseNumber(env.MAILSERVER_TIMEOUT_MS, 10_000),
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS, hostedDomains: parseList(env.HOSTED_DOMAINS, []),
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"), sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS), sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
globalRateLimit: raw.GLOBAL_RATE_LIMIT, resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
routeRateLimit: raw.ROUTE_RATE_LIMIT, appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000, appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
loginRateLimit: raw.LOGIN_RATE_LIMIT, appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000, isProduction: (env.NODE_ENV ?? "development") === "production"
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;
-60
View File
@@ -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<CryptoKey> => {
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<string> => {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return Buffer.from(digest).toString("hex");
};
export const encryptAtRest = async (value: string): Promise<string> => {
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<string> => {
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");
};
+135 -121
View File
@@ -1,20 +1,8 @@
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { unlink } from "node:fs/promises"; import { dirname } from "node:path";
import { dirname, isAbsolute, resolve } from "node:path";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { config } from "./config"; 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 = { type AdminRow = {
email: string; email: string;
password_hash: string; password_hash: string;
@@ -22,31 +10,28 @@ type AdminRow = {
updated_at: number; updated_at: number;
}; };
type ForwardingRow = { type SessionRow = {
email: string; token_hash: string;
target_email: string | null; admin_email: string;
enabled: number; expires_at: number;
updated_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(dirname(config.dbPath), { recursive: true });
mkdirSync(config.avatarDir, { recursive: true });
const db = new Database(config.dbPath, { create: true }); const db = new Database(config.dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;"); db.exec("PRAGMA foreign_keys = ON;");
db.exec(` 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 ( CREATE TABLE IF NOT EXISTS app_admins (
email TEXT PRIMARY KEY, email TEXT PRIMARY KEY,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
@@ -54,111 +39,140 @@ db.exec(`
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS forwarding_rules ( CREATE TABLE IF NOT EXISTS app_sessions (
email TEXT PRIMARY KEY, token_hash TEXT PRIMARY KEY,
target_email TEXT, admin_email TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL,
updated_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 getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
const upsertAdminStatement = db.query(` const upsertAdminStatement = db.query(`
INSERT INTO app_admins (email, password_hash, created_at, updated_at) INSERT INTO app_admins (email, password_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3) 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(` const insertSessionStatement = db.query(`
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at) INSERT INTO app_sessions (token_hash, admin_email, expires_at, created_at)
VALUES (?1, ?2, ?3, ?4) 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 getAdmin = (email: string): AdminRow | null =>
(getAdminStatement.get(email) as AdminRow | 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 upsertAdmin = (email: string, passwordHash: string): void => { 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(); const now = Date.now();
upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now); upsertAdminStatement.run(email, passwordHash, now);
return getForwardingRule(email)!;
}; };
export const deleteAvatarFile = async (avatarPath: string | null): Promise<void> => { export const createSession = (tokenHash: string, adminEmail: string, expiresAt: number): void => {
if (!avatarPath) { insertSessionStatement.run(tokenHash, adminEmail, expiresAt, Date.now());
return; };
}
export const getSession = (tokenHash: string): SessionRow | null =>
const absolutePath = toAbsoluteAvatarPath(avatarPath); (getSessionStatement.get(tokenHash) as SessionRow | null) ?? null;
if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) {
return; export const deleteSession = (tokenHash: string): void => {
} deleteSessionStatement.run(tokenHash);
};
try {
await unlink(absolutePath); export const createPasswordResetToken = (input: {
} catch { tokenHash: string;
// Best-effort cleanup of replaced avatars. 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);
}; };
+1 -9
View File
@@ -1,16 +1,8 @@
export class HttpError extends Error { export class HttpError extends Error {
readonly status: number; 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); super(message);
this.name = "HttpError";
this.status = status; this.status = status;
this.expose = options?.expose ?? true;
this.details = options?.details;
} }
} }
export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError;
+87 -46
View File
@@ -1,77 +1,118 @@
import { ZodError, type ZodSchema } from "zod";
import { config } from "./config"; import { config } from "./config";
import { HttpError, isHttpError } from "./errors"; import { HttpError } from "./errors";
import { buildResponseHeaders } from "./security";
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<string, string> => ({
"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), { new Response(JSON.stringify(payload), {
status, status,
headers: buildResponseHeaders(request, { headers: mergeHeaders(request, {
"Content-Type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
...init ...headers
}) })
}); });
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response => export const emptyResponse = (request: Request, status = 204, headers?: HeadersInit): Response =>
new Response(null, { new Response(null, {
status, status,
headers: buildResponseHeaders(request, init) headers: mergeHeaders(request, headers)
}); });
export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response => export const optionsResponse = (request: Request): Response =>
new Response(body as BodyInit, { emptyResponse(request, 204);
status: 200,
headers: buildResponseHeaders(request, {
"Content-Type": contentType,
"Cache-Control": "private, max-age=300",
...init
})
});
export const parseJson = async <T>(request: Request, schema: { parse(data: unknown): T }): Promise<T> => { export const parseJson = async <T>(request: Request, schema: ZodSchema<T>): Promise<T> => {
const contentType = request.headers.get("content-type") ?? ""; let body: unknown;
if (!contentType.toLowerCase().includes("application/json")) {
throw new HttpError(415, "Expected application/json request body.");
}
let payload: unknown;
try { try {
payload = await request.json(); body = await request.json();
} catch { } catch {
throw new HttpError(400, "Malformed JSON request body."); throw new HttpError(400, "Request body must be valid JSON.");
} }
try { try {
return schema.parse(payload); return schema.parse(body);
} catch (error) { } 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 => { export const errorResponse = (request: Request, error: unknown): Response => {
if (isHttpError(error)) { if (error instanceof HttpError) {
const retryAfterMs = return jsonResponse(request, error.status, {
typeof error.details === "object" && error: {
error.details !== null && message: error.message
"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( if (error instanceof ZodError) {
request, return jsonResponse(request, 400, {
error.status, error: {
{ message: error.issues[0]?.message ?? "Invalid request."
error: error.expose ? error.message : "An internal server error occurred." }
}, });
retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined
);
} }
if (config.nodeEnv !== "production") {
console.error(error); console.error(error);
}
return jsonResponse(request, 500, { return jsonResponse(request, 500, {
error: "An internal server error occurred." error: {
message: "Unexpected server error."
}
}); });
}; };
+50 -113
View File
@@ -1,119 +1,56 @@
import { config } from "./config"; import { createHash, randomBytes } from "node:crypto";
import { HttpError } from "./errors";
type RateLimitResult = { export const hashToken = (value: string): string =>
allowed: boolean; createHash("sha256").update(value).digest("hex");
retryAfterMs: number;
export const createOpaqueToken = (size = 32): string =>
randomBytes(size).toString("hex");
export const createTemporaryPassword = (): string =>
randomBytes(24).toString("base64url");
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}`
];
if (options.secure) {
parts.push("Secure");
}
return parts.join("; ");
}; };
type RateBucket = { export const buildClearedCookieValue = (name: string, secure: boolean): string =>
count: number; buildCookieValue(name, "", { maxAgeSeconds: 0, secure });
resetAt: number;
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 null;
}; };
export class SlidingWindowLimiter { export const sanitizeHeaderValue = (value: string): string =>
private readonly buckets = new Map<string, RateBucket>(); value.replace(/[\r\n]+/g, " ").trim();
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 globalLimiter = new SlidingWindowLimiter();
export const routeLimiter = new SlidingWindowLimiter();
export const loginLimiter = new SlidingWindowLimiter();
export const getClientIp = (request: Request): string => {
if (config.trustProxy) {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]!.trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp?.trim()) {
return realIp.trim();
}
}
return "0.0.0.0";
};
export const assertAllowedOrigin = (request: Request): void => {
const origin = request.headers.get("origin");
if (!origin) {
return;
}
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 }
});
}
};
+33 -83
View File
@@ -1,105 +1,55 @@
import { z } from "zod"; import { z } from "zod";
import { config } from "./config"; import { config } from "./config";
import { HttpError } from "./errors";
const emailRegex = const baseEmailSchema = z
/^(?=.{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
.string() .string()
.trim() .trim()
.toLowerCase() .toLowerCase()
.regex(emailRegex, "Invalid email address.") .email("Enter a valid email address.");
.refine((value) => !value.includes(".."), "Invalid email address.");
export const hostedMailboxSchema = strictEmailSchema.refine((email) => { const hostedDomainSet = new Set(config.hostedDomains);
const domain = email.split("@")[1] ?? "";
return config.hostedDomains.includes(domain); export const hostedEmailSchema = baseEmailSchema.superRefine((value, ctx) => {
}, "Mailbox domain is not hosted by this server."); 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({ export const loginSchema = z.object({
email: strictEmailSchema, email: baseEmailSchema,
password: z.string().min(8).max(256), password: z.string().min(1, "Password is required.")
totpCode: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional()
}); });
export const profileSchema = z.object({ export const createUserSchema = z.object({
displayName: z.string().trim().min(1).max(80) email: hostedEmailSchema
}); });
export const composeSchema = z.object({ export const updatePasswordSchema = z.object({
to: z.array(strictEmailSchema).min(1).max(10), password: passwordSchema
subject: z.string().trim().max(160),
textBody: z.string().trim().min(1).max(20_000)
}); });
export const forwardingSchema = z export const resetTokenSchema = z.object({
.object({ token: z.string().trim().min(32, "Invalid reset token.")
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({ export const resetPasswordSchema = z.object({
code: z.string().trim().regex(/^\d{6}$/) token: z.string().trim().min(32, "Invalid reset token."),
password: passwordSchema
}); });
export const messageIdSchema = z.object({ export const routeEmailSchema = z.object({
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/) email: hostedEmailSchema
}); });
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => { export const parseRouteEmail = (value: string): string =>
if (!target) { routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
return;
}
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;
};
+19 -194
View File
@@ -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 { config } from "../lib/config";
import { HttpError } from "../lib/errors"; import { sanitizeHeaderValue } from "../lib/security";
import { strictEmailSchema } from "../lib/validation"; import { sendMail } from "../docker/mailserver";
import { getSupportedFolder, listInboxFiles, readMailboxFile, sendMessage } from "../docker/mailserver";
const parser = new PostalMime(); export const sendPasswordResetEmail = async (email: string, resetLink: string, expiresAt: string): Promise<void> => {
const subject = sanitizeHeaderValue("Set your mailbox password");
const from = sanitizeHeaderValue(config.appMailFrom);
const to = sanitizeHeaderValue(email);
const createMessageId = (containerPath: string): string => const message = [
createHash("sha256").update(containerPath).digest("hex").slice(0, 32); `From: Mail Admin <${from}>`,
`To: <${to}>`,
const toText = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); `Subject: ${subject}`,
"Content-Type: text/plain; charset=utf-8",
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<string, unknown>;
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<string, string>) => ({
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<string, unknown>;
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<MailMessageDetail> => {
const parsed = (await parser.parse(raw)) as Record<string, unknown>;
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<MailMessageSummary[]> => {
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<MailMessageDetail> => {
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<void> => {
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",
"", "",
cleanText "A mailbox password reset was requested for your account.",
].join("\r\n"); "",
`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();
-107
View File
@@ -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<ProfileSettings> => {
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<ForwardingRule> => {
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
};
};
-29
View File
@@ -1,29 +0,0 @@
declare module "sanitize-html" {
type SanitizeOptions = {
allowedTags?: string[];
allowedAttributes?: Record<string, string[]>;
allowedSchemes?: string[];
transformTags?: Record<
string,
(tagName: string, attribs: Record<string, string>) => {
tagName: string;
attribs: Record<string, string>;
}
>;
};
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, unknown>;
}>;
}
): string;
}
+143
View File
@@ -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<MailUser> => {
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<MailUser[]> =>
listMailUsers();
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
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<void> => {
await ensureUserExists(email);
await updateMailUserPassword(email, password);
};
export const deleteUser = async (email: string): Promise<void> => {
await ensureUserExists(email);
await deleteMailUser(email);
};
export const triggerPasswordReset = async (
email: string,
createdBy: string
): Promise<PasswordResetTriggerResponse> => {
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<PasswordResetInspectResponse> => {
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<void> => {
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;
}
};
-7
View File
@@ -1,13 +1,6 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM"
]
},
"include": [ "include": [
"src" "src"
] ]
} }
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx"
"rootDir": "src"
}, },
"include": [ "include": [
"src", "src",
+67 -115
View File
@@ -10,15 +10,11 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@internal-mail/shared": "workspace:*", "@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0", "elysia": "^1.3.10",
"html-to-text": "^9.0.5", "zod": "^3.25.0",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2",
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
}, },
}, },
@@ -27,23 +23,23 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@internal-mail/shared": "workspace:*", "@internal-mail/shared": "workspace:*",
"react": "^19.1.0", "react": "^19.1.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.1",
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.3", "@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^5.0.0",
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.4", "vite": "^7.0.0",
}, },
}, },
"packages/shared": { "packages/shared": {
"name": "@internal-mail/shared", "name": "@internal-mail/shared",
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
}, },
}, },
@@ -89,57 +85,57 @@
"@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]5.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/aix-ppc64": ["@esbuild/[email protected]7.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/[email protected]5.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], "@esbuild/android-arm": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]5.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], "@esbuild/android-arm64": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/[email protected]5.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], "@esbuild/android-x64": ["@esbuild/[email protected]7.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], "@esbuild/darwin-arm64": ["@esbuild/[email protected]7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], "@esbuild/darwin-x64": ["@esbuild/[email protected]7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]5.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], "@esbuild/freebsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]5.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], "@esbuild/freebsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], "@esbuild/linux-arm": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], "@esbuild/linux-arm64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], "@esbuild/linux-ia32": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], "@esbuild/linux-loong64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], "@esbuild/linux-mips64el": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], "@esbuild/linux-ppc64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], "@esbuild/linux-riscv64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], "@esbuild/linux-s390x": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/linux-x64": ["@esbuild/[email protected]7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]5.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], "@esbuild/netbsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]5.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], "@esbuild/netbsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]5.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], "@esbuild/openbsd-arm64": ["@esbuild/[email protected]7.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]5.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], "@esbuild/openbsd-x64": ["@esbuild/[email protected]7.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]5.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], "@esbuild/openharmony-arm64": ["@esbuild/[email protected]7.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]5.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], "@esbuild/sunos-x64": ["@esbuild/[email protected]7.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], "@esbuild/win32-arm64": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]5.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], "@esbuild/win32-ia32": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/[email protected]7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@internal-mail/api": ["@internal-mail/api@workspace:apps/api"], "@internal-mail/api": ["@internal-mail/api@workspace:apps/api"],
@@ -157,61 +153,57 @@
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@noble/hashes": ["@noble/[email protected]", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], "@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "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[email protected].0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="], "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin[email protected].1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm[email protected].0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="], "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x[email protected].1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x[email protected].0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="], "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm[email protected].1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm[email protected].0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="], "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x[email protected].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-gnu[email protected].0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="], "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musl[email protected].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[email protected].0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="], "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64[email protected].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-x[email protected].0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="], "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm[email protected].1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony[email protected].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[email protected].0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="], "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32[email protected].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.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@selderee/plugin-htmlparser2": ["@selderee/[email protected]", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], "@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
@@ -235,7 +227,7 @@
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/[email protected]", "", { "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": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], "baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
@@ -243,7 +235,7 @@
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"caniuse-lite": ["[email protected]1", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], "caniuse-lite": ["[email protected]2", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -253,28 +245,14 @@
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="], "electron-to-chromium": ["[email protected]", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="],
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"esbuild": ["[email protected]", "", { "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=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -287,22 +265,14 @@
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"html-to-text": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leac": ["[email protected]", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
@@ -315,36 +285,22 @@
"openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"otpauth": ["[email protected]", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA=="],
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
"parseley": ["[email protected]", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"peberminta": ["[email protected]", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postal-mime": ["[email protected]", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["[email protected]7.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["[email protected]8.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["[email protected].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=="], "rollup": ["[email protected].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=="],
"sanitize-html": ["[email protected]", "", { "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=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"selderee": ["[email protected]", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -363,14 +319,10 @@
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["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": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sanitize-html/htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"sanitize-html/htmlparser2/entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
} }
} }
+15 -38
View File
@@ -1,41 +1,18 @@
server {
listen 443 ssl http2;
server_name mail.example.com;
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;
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 "";
location /api/ {
proxy_pass http://api:3001/api/;
}
location / {
proxy_pass http://web:5173/;
}
}
server { server {
listen 80; listen 80;
server_name mail.example.com; server_name _;
return 301 https://$host$request_uri;
location / {
proxy_pass http://frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
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;
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "internal-mail-server", "name": "internal-mail-server",
"private": true, "private": true,
"packageManager": "[email protected]1", "packageManager": "[email protected]0",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
+1 -2
View File
@@ -10,8 +10,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.3.11", "bun-types": "^1.3.10",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }
+54 -51
View File
@@ -1,60 +1,63 @@
export type UserRole = "admin" | "user"; export type AdminSession = {
export interface SessionUser {
email: string;
displayName: string;
role: UserRole;
avatarUrl: string | null;
totpEnabled: boolean;
}
export interface SessionView {
authenticated: boolean; authenticated: boolean;
csrfToken: string | null; adminEmail: string | null;
user: SessionUser | null; };
}
export interface ForwardingRule { export type SessionResponse = {
enabled: boolean; session: AdminSession;
target: string | null; };
allowedDomains: string[];
}
export interface ProfileSettings { export type MailUser = {
email: string; email: string;
displayName: string; localPart: string;
role: UserRole; domain: string;
avatarUrl: string | null; };
totpEnabled: boolean;
}
export interface MailAttachment { export type UsersResponse = {
filename: string; items: MailUser[];
mimeType: string; };
size: number | null;
}
export interface MailMessageSummary { export type CreateUserRequest = {
id: string; email: string;
from: string; };
subject: string;
preview: string;
receivedAt: string;
hasHtml: boolean;
}
export interface MailMessageDetail extends MailMessageSummary { export type CreateUserResponse = {
to: string[]; user: MailUser;
textBody: string; resetLinkSent: boolean;
safeHtml: string | null; resetLinkExpiresAt: string | null;
attachments: MailAttachment[]; warning: string | null;
} };
export interface LoginResponse { export type UpdateUserPasswordRequest = {
session: SessionView; 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;
}
+2 -3
View File
@@ -2,11 +2,10 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"declaration": true, "rootDir": "src",
"emitDeclarationOnly": true "noEmit": false
}, },
"include": [ "include": [
"src" "src"
] ]
} }
-2
View File
@@ -7,7 +7,6 @@
"noEmit": true, "noEmit": true,
"skipLibCheck": true, "skipLibCheck": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"types": [ "types": [
@@ -21,4 +20,3 @@
} }
} }
} }