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_PORT=3001
APP_ORIGINS=http://localhost:5173
APP_EXTERNAL_BASE_URL=https://mail.example.com
APP_EXTERNAL_BASE_URL=http://localhost:5173
APP_DATA_PATH=../../data/app.db
MAILSERVER_CONTAINER=mailserver
DOCKER_BINARY=docker
MAILSERVER_TIMEOUT_MS=10000
HOSTED_DOMAINS=example.com
FORWARDING_ALLOWED_DOMAINS=example.com
SESSION_TTL_MINUTES=15
GLOBAL_RATE_LIMIT=240
ROUTE_RATE_LIMIT=90
RATE_LIMIT_WINDOW_SECONDS=60
LOGIN_RATE_LIMIT=5
LOGIN_WINDOW_SECONDS=600
MAX_MESSAGES=25
AVATAR_MAX_BYTES=2097152
TRUST_PROXY=false
SESSION_COOKIE_NAME=mail_admin_session
SESSION_TTL_MINUTES=480
RESET_TOKEN_TTL_MINUTES=60
APP_ADMIN_EMAIL=[email protected]
APP_ADMIN_PASSWORD=change-me-now
# 32-byte base64 key used to encrypt TOTP secrets at rest.
TOTP_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY
APP_MAIL_FROM=[email protected]
+2 -6
View File
@@ -5,10 +5,6 @@ build
.env
.env.local
data/app.db
data/avatars/*
!data/avatars/.gitkeep
data/app.db-shm
data/app.db-wal
coverage
apps/api/data/app.db
apps/api/data/app.db-shm
apps/api/data/app.db-wal
+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
- Frontend: React + Vite running on Bun
- Storage: Bun SQLite for app metadata, sessions, user preferences, and encrypted TOTP secrets
- Mail integration: Docker CLI with fixed argument arrays only
### Backend
## Security-first design
- Bun
- ElysiaJS
- Bun SQLite
- Docker CLI through fixed argument arrays
- Zod for small, explicit validation
### Critical risks and mitigations
Why this backend:
| Risk | Mitigation |
| --- | --- |
| Command injection through Docker CLI | All Docker calls use `Bun.spawn()` with explicit argument arrays. No shell string concatenation, no `sh -c`, no raw user input in command strings. |
| Session theft or token leakage | Server-side opaque sessions in `httpOnly`, `SameSite=Strict` cookies. The frontend never stores session identifiers in `localStorage`. |
| CSRF | Every state-changing route requires an in-memory CSRF token in `X-CSRF-Token` plus the authenticated cookie. |
| XSS from HTML email | HTML email is sanitized on the backend, dangerous tags are removed, remote images are not allowed, and the frontend renders sanitized HTML only in a sandboxed iframe. |
| Brute-force login | Dedicated login rate limiter plus a global limiter. |
| Unsafe file uploads | Avatar uploads are size-limited, magic-byte checked, renamed with random tokens, stored outside the public root, and served through an authenticated endpoint. |
| Forwarding abuse | Forwarding targets are strictly validated, self-forwarding is blocked, and allowed target domains are policy-controlled. |
| Secret exposure | No passwords or TOTP secrets are logged. TOTP secrets are encrypted at rest with AES-GCM via `TOTP_ENCRYPTION_KEY`. |
| Information leakage | API errors return generic messages for internal failures and use explicit HTTP status codes for validation and auth issues. |
- Bun keeps the runtime, package manager, and SQLite support simple.
- ElysiaJS is a good fit for a small internal API without extra framework weight.
- SQLite is enough for admin sessions and password reset tokens, and keeps deployment straightforward.
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
### Why server-side sessions instead of JWTs
### Frontend
This project uses secure server-side sessions instead of bearer tokens or long-lived JWTs. That keeps revocation simple, reduces token exposure risk in the browser, and avoids putting sensitive auth state in JavaScript storage.
- React
- Vite
- Plain CSS
## Features
Why React:
- Mailbox login against docker-mailserver via `doveadm auth test`
- Optional seeded local admin account with Argon2id-hashed password
- Inbox listing and message preview from Maildir inside the mail container
- Plain-text compose flow using `sendmail` inside the container
- Safe HTML email preview
- Profile settings, avatar upload, and forwarding management
- Optional TOTP two-factor authentication
- Security headers and restricted CORS
- It is easy for most full-stack teams to read and maintain.
- This panel only needs basic local state and forms, so React works well without routers or global state libraries.
- The UI stays minimal while still being easy to grow later.
## Project layout
## 2. Folder structure
```text
apps/
api/ Elysia backend
web/ React/Vite frontend
api/
src/
auth/
docker/
lib/
mail/
users/
web/
src/
api/
components/
pages/
styles/
packages/
shared/ Shared TypeScript types
infra/
nginx/ Reverse proxy example with security headers
shared/
data/
avatars/ Stored outside the public web root
app.db
infra/
nginx/
```
## Local development
## 3. Backend implementation
### 1. Configure environment
The backend is intentionally flat:
Copy `.env.example` to `.env` and update the values:
- `apps/api/src/index.ts`
Defines the HTTP routes and keeps request flow obvious.
- `apps/api/src/auth/service.ts`
Handles admin login, bootstrap admin seeding, session cookies, and session lookup.
- `apps/api/src/docker/`
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
- `apps/api/src/users/service.ts`
Contains the mailbox user operations and password reset flow.
- `apps/api/src/mail/service.ts`
Sends reset emails through `sendmail` inside the Docker Mailserver container.
- `apps/api/src/lib/`
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
- `HOSTED_DOMAINS`: domains served by docker-mailserver
- `MAILSERVER_CONTAINER`: container name to run `docker exec` against
- `APP_ORIGINS`: allowed frontend origins
- `TOTP_ENCRYPTION_KEY`: base64-encoded 32-byte key
- `APP_ADMIN_EMAIL` / `APP_ADMIN_PASSWORD`: optional local admin bootstrap account
### API design
Generate a 32-byte base64 key:
- `GET /api/auth/session`
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `GET /api/users`
- `POST /api/users`
- `PATCH /api/users/:email/password`
- `DELETE /api/users/:email`
- `POST /api/users/:email/password-reset`
- `POST /api/password-resets/inspect`
- `POST /api/password-resets/consume`
## 4. Frontend implementation
The frontend is a small React app with three views:
- Login page
- Dashboard page
- Public reset-password page
The dashboard includes:
- mailbox listing
- simple search
- create user modal
- edit password modal
- delete action
- send reset action
The create flow intentionally generates a temporary password on the server and immediately emails a one-time reset link to the user. That means the admin panel never needs to expose an initial mailbox password in the UI.
## 5. Password reset flow
The reset flow is built to stay secure without becoming heavy:
1. An admin triggers a reset for an existing mailbox.
2. The backend generates a random opaque token.
3. Only the SHA-256 hash of that token is stored in SQLite.
4. The user receives an email with the one-time reset link.
5. The reset page validates the token before showing the password form.
6. On submit, the token is claimed for one use only.
7. The backend updates the mailbox password through Docker Mailserver.
8. Used or expired tokens are rejected.
## 6. Docker CLI integration strategy
The app talks to Docker Mailserver through `docker exec` with explicit argument arrays:
- `setup email list`
- `setup email add <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
openssl rand -base64 32
bun install
```
### 2. Install dependencies
If Bun needs an explicit temp dir on your machine:
4. Start the API:
```bash
BUN_TMPDIR=/tmp bun install
bun run dev:api
```
### 3. Start the apps
Backend:
5. In another terminal, start the frontend:
```bash
bun run --cwd apps/api dev
bun run dev:web
```
Frontend:
6. Open `http://localhost:5173`.
```bash
bun run --cwd apps/web dev
```
## Notes and assumptions
The Vite dev server proxies `/api` to `http://localhost:3001`.
Important en local:
- le compte admin local est créé au démarrage de l'API
- il faut donc avoir un fichier `.env` à la racine du repo
- et redémarrer l'API après tout changement de `APP_ADMIN_EMAIL` ou `APP_ADMIN_PASSWORD`
## Docker CLI assumptions
The backend assumes docker-mailserver is available in a running container and that the following commands are present:
- `doveadm auth test` for mailbox authentication
- `find` and `cat` for safe Maildir reads
- `sendmail` for sending plain-text messages
- `setup alias add|del` for forwarding aliases
The mailbox reader currently targets the standard Maildir layout:
```text
/var/mail/<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.
- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands.
- The admin account for the panel is separate from mailbox users.
- The app stores only panel-specific metadata in SQLite. Mailbox accounts themselves remain managed by Docker Mailserver.
+3 -7
View File
@@ -9,15 +9,11 @@
},
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0",
"html-to-text": "^9.0.5",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2"
"elysia": "^1.3.10",
"zod": "^3.25.0"
},
"devDependencies": {
"bun-types": "^1.3.11",
"bun-types": "^1.3.10",
"typescript": "^5.8.3"
}
}
+62 -136
View File
@@ -1,174 +1,100 @@
import * as OTPAuth from "otpauth";
import type { SessionView, UserRole } from "@internal-mail/shared";
import { authenticateMailbox } from "../docker/mailserver";
import type { AdminSession } from "@internal-mail/shared";
import { config } from "../lib/config";
import { decryptAtRest, encryptAtRest } from "../lib/crypto";
import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db";
import { createSession, deleteSession, getAdmin, getSession, upsertAdmin } from "../lib/db";
import { HttpError } from "../lib/errors";
import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation";
import {
buildClearedCookieValue,
buildCookieValue,
createOpaqueToken,
hashToken,
readCookie
} from "../lib/security";
const issuer = "Secure Webmail";
const cookieMaxAgeSeconds = Math.floor(config.sessionTtlMs / 1000);
const createTotp = (email: string, secret: string): OTPAuth.TOTP =>
new OTPAuth.TOTP({
issuer,
label: email,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret)
});
const buildSessionView = (email: string, role: UserRole, csrfToken: string | null): SessionView => {
const profile = ensureProfile(email, role);
return {
authenticated: true,
csrfToken,
user: {
email: profile.email,
displayName: profile.display_name,
role,
avatarUrl: profile.avatar_path ? `/api/settings/avatar/${encodeURIComponent(profile.email)}` : null,
totpEnabled: Boolean(profile.totp_enabled)
}
};
};
const sessionView = (adminEmail: string | null): AdminSession => ({
authenticated: Boolean(adminEmail),
adminEmail
});
export const seedAdminAccount = async (): Promise<void> => {
if (!config.adminEmail || !config.adminPassword) {
if (!config.appAdminEmail || !config.appAdminPassword) {
return;
}
const existing = getAdmin(config.adminEmail);
if (existing && (await Bun.password.verify(config.adminPassword, existing.password_hash))) {
ensureProfile(config.adminEmail, "admin");
const existing = getAdmin(config.appAdminEmail);
if (existing && (await Bun.password.verify(config.appAdminPassword, existing.password_hash))) {
return;
}
const passwordHash = await Bun.password.hash(config.adminPassword, {
const passwordHash = await Bun.password.hash(config.appAdminPassword, {
algorithm: "argon2id",
memoryCost: 19456,
memoryCost: 19_456,
timeCost: 2
});
upsertAdmin(config.adminEmail, passwordHash);
upsertAdmin(config.appAdminEmail, passwordHash);
};
export const login = async (
email: string,
password: string,
totpCode: string | undefined,
_request: Request
): Promise<{ session: SessionView }> => {
const normalizedEmail = strictEmailSchema.parse(email);
let role: UserRole = "user";
if (config.allowInsecureLogin) {
if (config.adminEmail && normalizedEmail === config.adminEmail) {
role = "admin";
}
return {
session: buildSessionView(normalizedEmail, role, null)
};
}
const localAdmin = getAdmin(normalizedEmail);
if (localAdmin) {
const valid = await Bun.password.verify(password, localAdmin.password_hash);
if (!valid) {
export const loginAdmin = async (email: string, password: string): Promise<{
session: AdminSession;
cookie: string;
}> => {
const admin = getAdmin(email);
if (!admin) {
throw new HttpError(401, "Invalid email or password.");
}
role = "admin";
} else {
hostedMailboxSchema.parse(normalizedEmail);
const isValid = await authenticateMailbox(normalizedEmail, password);
if (!isValid) {
const validPassword = await Bun.password.verify(password, admin.password_hash);
if (!validPassword) {
throw new HttpError(401, "Invalid email or password.");
}
}
const profile = ensureProfile(normalizedEmail, role);
if (profile.totp_enabled) {
if (!totpCode) {
throw new HttpError(401, "Two-factor code is required.");
}
const rawToken = createOpaqueToken();
const tokenHash = hashToken(rawToken);
const expiresAt = Date.now() + config.sessionTtlMs;
const decryptedSecret = await decryptAtRest(profile.totp_secret!);
const delta = createTotp(normalizedEmail, decryptedSecret).validate({ token: totpCode, window: 1 });
if (delta === null) {
throw new HttpError(401, "Invalid two-factor code.");
}
}
createSession(tokenHash, admin.email, expiresAt);
return {
session: buildSessionView(normalizedEmail, role, null)
session: sessionView(admin.email),
cookie: buildCookieValue(config.sessionCookieName, rawToken, {
maxAgeSeconds: cookieMaxAgeSeconds,
secure: config.isProduction
})
};
};
export const getSessionFromRequest = async (request: Request) => {
const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail;
if (!headerEmail) {
throw new HttpError(401, "X-User-Email header is required.");
export const logoutAdmin = (request: Request): string => {
const rawToken = readCookie(request, config.sessionCookieName);
if (rawToken) {
deleteSession(hashToken(rawToken));
}
const email = strictEmailSchema.parse(headerEmail);
const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user";
ensureProfile(email, role);
return buildClearedCookieValue(config.sessionCookieName, config.isProduction);
};
export const requireAdminSession = (request: Request): { adminEmail: string } => {
const rawToken = readCookie(request, config.sessionCookieName);
if (!rawToken) {
throw new HttpError(401, "Authentication required.");
}
const session = getSession(hashToken(rawToken));
if (!session || session.expires_at <= Date.now()) {
throw new HttpError(401, "Authentication required.");
}
return {
id: "stateless",
email,
role,
csrfToken: null,
session: buildSessionView(email, role, null)
adminEmail: session.admin_email
};
};
export const getAnonymousSession = (): SessionView => ({
authenticated: false,
csrfToken: null,
user: null
});
export const createTotpSecret = async (email: string): Promise<{ secret: string; otpauthUrl: string }> => {
const profile = getProfile(email) ?? ensureProfile(email);
const secret = new OTPAuth.Secret({ size: 20 }).base32;
const encrypted = await encryptAtRest(secret);
setTotpSecret(profile.email, encrypted, false);
return {
secret,
otpauthUrl: createTotp(email, secret).toString()
};
export const getSessionFromRequest = (request: Request): AdminSession => {
try {
return sessionView(requireAdminSession(request).adminEmail);
} catch {
return sessionView(null);
}
};
export const enableTotp = async (email: string, code: string): Promise<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 { HttpError } from "../lib/errors";
import { hostedMailboxSchema } from "../lib/validation";
import type { MailUser } from "@internal-mail/shared";
import { runDocker } from "./runner";
const MAILDIR_FOLDER = "INBOX";
const infrastructureErrorPatterns = [
/cannot connect to the docker daemon/i,
/permission denied/i,
/no such container/i,
/is not running/i,
/executable file not found/i,
/no such file or directory/i,
/command not found/i
];
const emailPattern = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
const looksLikeInfrastructureFailure = (output: string): boolean =>
infrastructureErrorPatterns.some((pattern) => pattern.test(output));
const toMailUser = (email: string): MailUser => {
const [rawLocalPart, rawDomain] = email.split("@");
const localPart = rawLocalPart ?? email;
const domain = rawDomain ?? "";
const localPartRegex = /^[A-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}$/i;
return {
email,
localPart,
domain
};
};
const splitHostedEmail = (email: string): { localPart: string; domain: string } => {
const normalized = hostedMailboxSchema.parse(email);
const [localPart, domain] = normalized.split("@");
if (!localPart || !domain || !localPartRegex.test(localPart)) {
throw new HttpError(400, "Unsupported mailbox format.");
export const listMailUsers = async (): Promise<MailUser[]> => {
const { stdout } = await runDocker(["setup", "email", "list"]);
const emails = new Set<string>();
for (const match of stdout.matchAll(emailPattern)) {
emails.add(match[0].toLowerCase());
}
return { localPart, domain };
return [...emails].sort().map(toMailUser);
};
const buildMaildirRoot = (email: string): string => {
const { localPart, domain } = splitHostedEmail(email);
return `/var/mail/${domain}/${localPart}/Maildir`;
export const addMailUser = async (email: string, password: string): Promise<void> => {
await runDocker(["setup", "email", "add", email, password]);
};
export const authenticateMailbox = async (email: string, password: string): Promise<boolean> => {
const normalizedEmail = hostedMailboxSchema.parse(email);
const result = await runDocker(
["exec", config.mailserverContainer, "doveadm", "auth", "test", normalizedEmail, password],
{ allowNonZero: true }
);
const combinedOutput = `${result.stdout}\n${result.stderr}`.trim();
if (result.exitCode !== 0 && looksLikeInfrastructureFailure(combinedOutput)) {
throw new HttpError(502, "Mail authentication backend is unavailable.");
}
return result.exitCode === 0;
export const updateMailUserPassword = async (email: string, password: string): Promise<void> => {
await runDocker(["setup", "email", "update", email, password]);
};
export const listInboxFiles = async (email: string): Promise<string[]> => {
const root = buildMaildirRoot(email);
const result = await runDocker([
"exec",
config.mailserverContainer,
"find",
`${root}/cur`,
`${root}/new`,
"-maxdepth",
"1",
"-type",
"f"
]);
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort()
.reverse();
export const deleteMailUser = async (email: string): Promise<void> => {
await runDocker(["setup", "email", "del", email]);
};
export const readMailboxFile = async (containerPath: string): Promise<string> => {
if (!containerPath.startsWith("/var/mail/")) {
throw new HttpError(400, "Invalid mail file path.");
}
const result = await runDocker(["exec", config.mailserverContainer, "cat", containerPath]);
return result.stdout;
export const sendMail = async (rawMessage: string): Promise<void> => {
await runDocker(["sendmail", "-t", "-i"], rawMessage);
};
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 { HttpError } from "../lib/errors";
type DockerRunOptions = {
stdinText?: string;
allowNonZero?: boolean;
};
export type DockerRunResult = {
type DockerResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export class DockerCommandError extends Error {
readonly exitCode: number;
readonly stderr: string;
constructor(message: string, exitCode: number, stderr: string) {
super(message);
this.name = "DockerCommandError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
export const runDocker = async (args: string[], options?: DockerRunOptions): Promise<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);
export const runDocker = async (
args: string[],
input?: string
): Promise<DockerResult> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.mailserverTimeoutMs);
try {
if (options?.stdinText && process.stdin) {
await process.stdin.write(new TextEncoder().encode(options.stdinText));
await process.stdin.end();
const child = spawn(
config.dockerBinary,
["exec", ...(input ? ["-i"] : []), config.mailserverContainer, ...args],
{
shell: false,
signal: controller.signal,
stdio: ["pipe", "pipe", "pipe"]
}
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
if (input) {
child.stdin.write(input);
}
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
child.stdin.end();
if (exitCode !== 0 && !options?.allowNonZero) {
throw new DockerCommandError("Docker command failed.", exitCode, stderr);
const exitCode = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => resolve(code ?? 1));
});
if (exitCode !== 0) {
throw new HttpError(502, stderr.trim() || stdout.trim() || "Docker Mailserver command failed.");
}
return { stdout, stderr, exitCode };
return {
stdout,
stderr
};
} catch (error) {
if (error instanceof DockerCommandError) {
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(504, "Docker command timed out or failed to execute.", {
expose: false
});
if (error instanceof Error && error.name === "AbortError") {
throw new HttpError(504, "Docker Mailserver command timed out.");
}
throw new HttpError(502, "Docker Mailserver is unreachable.");
} finally {
clearTimeout(timeout);
}
+101 -145
View File
@@ -1,21 +1,43 @@
import { Elysia } from "elysia";
import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service";
import { HttpError } from "./lib/errors";
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, seedAdminAccount } from "./auth/service";
import { cleanupDatabase } from "./lib/db";
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config";
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security";
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation";
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service";
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service";
import {
createUserSchema,
loginSchema,
parseRouteEmail,
resetPasswordSchema,
resetTokenSchema,
updatePasswordSchema
} from "./lib/validation";
import {
consumePasswordReset,
createUser,
deleteUser,
inspectPasswordReset,
listUsers,
triggerPasswordReset,
updateUserPassword
} from "./users/service";
const app = new Elysia();
const route = (handler: (request: Request, params?: Record<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> }) => {
if (request.method === "OPTIONS") {
return optionsResponse(request);
}
try {
assertAllowedOrigin(request);
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs);
return await handler(request, params);
cleanupDatabase();
return await handler({ request, params });
} catch (error) {
return errorResponse(request, error);
}
@@ -23,179 +45,113 @@ const route = (handler: (request: Request, params?: Record<string, string>) => P
await seedAdminAccount();
app.onRequest(({ request }) => {
if (request.method === "OPTIONS") {
return emptyResponse(request, 204);
}
});
app.get(
"/api/health",
route(async (request) =>
route(async ({ request }) =>
jsonResponse(request, 200, {
ok: true,
mode: config.nodeEnv,
folder: getMailboxFolderLabel()
ok: true
})
)
);
app.get(
"/api/auth/session",
route(async (request) => {
try {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, { session: session.session });
} catch {
return jsonResponse(request, 200, { session: getAnonymousSession() });
}
route(async ({ request }) =>
jsonResponse(request, 200, {
session: getSessionFromRequest(request)
})
)
);
app.post(
"/api/auth/login",
route(async (request) => {
if (!config.allowInsecureLogin) {
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
}
route(async ({ request }) => {
const body = await parseJson(request, loginSchema);
const result = await login(body.email, body.password, body.totpCode, request);
const result = await loginAdmin(body.email, body.password);
return jsonResponse(
request,
200,
{ session: result.session }
{ session: result.session },
{ "set-cookie": result.cookie }
);
})
);
app.post(
"/api/auth/logout",
route(async (request) => {
return jsonResponse(request, 200, { session: getAnonymousSession() });
})
);
app.post(
"/api/auth/totp/setup",
route(async (request) => {
const session = await getSessionFromRequest(request);
const setup = await createTotpSecret(session.email);
return jsonResponse(request, 200, setup);
})
);
app.post(
"/api/auth/totp/enable",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, totpCodeSchema);
await enableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
);
app.post(
"/api/auth/totp/disable",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, totpCodeSchema);
await disableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
route(async ({ request }) =>
jsonResponse(
request,
200,
{ session: { authenticated: false, adminEmail: null } },
{ "set-cookie": logoutAdmin(request) }
)
)
);
app.get(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getProfileSettings(session.email));
"/api/users",
route(async ({ request }) => {
requireAdminSession(request);
return jsonResponse(request, 200, { items: await listUsers() });
})
);
app.post(
"/api/users",
route(async ({ request }) => {
const session = requireAdminSession(request);
const body = await parseJson(request, createUserSchema);
const result = await createUser(body.email, session.adminEmail);
return jsonResponse(request, 201, result);
})
);
app.patch(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, profileSchema);
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
"/api/users/:email/password",
route(async ({ request, params }) => {
requireAdminSession(request);
const email = parseRouteEmail(params.email);
const body = await parseJson(request, updatePasswordSchema);
await updateUserPassword(email, body.password);
return emptyResponse(request, 204);
})
);
app.delete(
"/api/users/:email",
route(async ({ request, params }) => {
requireAdminSession(request);
const email = parseRouteEmail(params.email);
await deleteUser(email);
return emptyResponse(request, 204);
})
);
app.post(
"/api/settings/avatar",
route(async (request) => {
const session = await getSessionFromRequest(request);
const formData = await request.formData();
const avatar = formData.get("avatar");
if (!(avatar instanceof File)) {
throw new HttpError(400, "Avatar upload is required.");
}
const profile = await saveAvatar(session.email, avatar);
return jsonResponse(request, 200, profile);
})
);
app.get(
"/api/settings/avatar/:email",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
const targetEmail = strictEmailSchema.parse(params?.email ?? "");
if (targetEmail !== session.email && session.role !== "admin") {
throw new HttpError(403, "You can only access your own avatar.");
}
const file = getAvatarFile(targetEmail);
return fileResponse(request, file, file.type || "application/octet-stream");
})
);
app.get(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getForwardingSettings(session.email));
})
);
app.put(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, forwardingSchema);
const rule = await updateForwarding(session.email, body.enabled, body.target);
return jsonResponse(request, 200, rule);
})
);
app.get(
"/api/mail/messages",
route(async (request) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "messages", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const items = await listMessagesForMailbox(session.email);
return jsonResponse(request, 200, { items });
})
);
app.get(
"/api/mail/messages/:id",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "message-detail", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const { id } = messageIdSchema.parse({ id: params?.id ?? "" });
const item = await getMessageForMailbox(session.email, id);
return jsonResponse(request, 200, item);
"/api/users/:email/password-reset",
route(async ({ request, params }) => {
const session = requireAdminSession(request);
const email = parseRouteEmail(params.email);
const result = await triggerPasswordReset(email, session.adminEmail);
return jsonResponse(request, 200, result);
})
);
app.post(
"/api/mail/send",
route(async (request) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
const body = await parseJson(request, composeSchema);
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
"/api/password-resets/inspect",
route(async ({ request }) => {
const body = await parseJson(request, resetTokenSchema);
return jsonResponse(request, 200, await inspectPasswordReset(body.token));
})
);
app.post(
"/api/password-resets/consume",
route(async ({ request }) => {
const body = await parseJson(request, resetPasswordSchema);
await consumePasswordReset(body.token, body.password);
return emptyResponse(request, 204);
})
);
@@ -205,4 +161,4 @@ app.listen({
port: config.port
});
console.log(`Secure webmail API listening on http://${config.host}:${config.port}`);
console.log(`Mail admin API listening on http://${config.host}:${config.port}`);
+29 -66
View File
@@ -1,73 +1,36 @@
import { resolve } from "node:path";
import { z } from "zod";
const normalizedEnv = Object.fromEntries(
Object.entries(Bun.env).map(([key, value]) => [key, value === "" ? undefined : value])
);
const env = Bun.env;
const splitList = (value: string | undefined, fallback: string): string[] =>
(value ?? fallback)
const parseList = (value: string | undefined, fallback: string[]): string[] =>
(value ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
.concat(fallback)
.filter((item, index, items) => items.indexOf(item) === index);
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
APP_HOST: z.string().default("0.0.0.0"),
APP_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
APP_ORIGINS: z.string().default("http://localhost:5173"),
APP_EXTERNAL_BASE_URL: z.string().url().default("http://localhost:5173"),
DATA_DIR: z.string().default("./data"),
MAILSERVER_CONTAINER: z
.string()
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/)
.default("mailserver"),
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
HOSTED_DOMAINS: z.string().default("example.com"),
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240),
ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90),
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60),
LOGIN_RATE_LIMIT: z.coerce.number().int().min(3).max(20).default(5),
LOGIN_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86400).default(600),
MAX_MESSAGES: z.coerce.number().int().min(1).max(100).default(25),
AVATAR_MAX_BYTES: z.coerce.number().int().min(1024).max(5_000_000).default(2_097_152),
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
APP_ADMIN_EMAIL: z.string().email().optional(),
APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(),
TOTP_ENCRYPTION_KEY: z.string().optional(),
ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false")
});
const raw = envSchema.parse(normalizedEnv);
if ((raw.APP_ADMIN_EMAIL && !raw.APP_ADMIN_PASSWORD) || (!raw.APP_ADMIN_EMAIL && raw.APP_ADMIN_PASSWORD)) {
throw new Error("APP_ADMIN_EMAIL and APP_ADMIN_PASSWORD must be provided together.");
}
const parseNumber = (value: string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
export const config = {
nodeEnv: raw.NODE_ENV,
host: raw.APP_HOST,
port: raw.APP_PORT,
externalBaseUrl: raw.APP_EXTERNAL_BASE_URL,
allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")),
dataDir: resolve(process.cwd(), raw.DATA_DIR),
avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"),
dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"),
mailserverContainer: raw.MAILSERVER_CONTAINER,
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
routeRateLimit: raw.ROUTE_RATE_LIMIT,
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
loginRateLimit: raw.LOGIN_RATE_LIMIT,
loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000,
maxMessages: raw.MAX_MESSAGES,
avatarMaxBytes: raw.AVATAR_MAX_BYTES,
trustProxy: raw.TRUST_PROXY === "true",
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
adminPassword: raw.APP_ADMIN_PASSWORD ?? null,
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null,
allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true"
} as const;
nodeEnv: env.NODE_ENV ?? "development",
host: env.APP_HOST ?? "0.0.0.0",
port: parseNumber(env.APP_PORT, 3001),
appOrigins: parseList(env.APP_ORIGINS, ["http://localhost:5173"]),
externalBaseUrl: (env.APP_EXTERNAL_BASE_URL ?? "http://localhost:5173").replace(/\/+$/, ""),
dbPath: resolve(process.cwd(), env.APP_DATA_PATH ?? "../../data/app.db"),
mailserverContainer: env.MAILSERVER_CONTAINER ?? "mailserver",
dockerBinary: env.DOCKER_BINARY ?? "docker",
mailserverTimeoutMs: parseNumber(env.MAILSERVER_TIMEOUT_MS, 10_000),
hostedDomains: parseList(env.HOSTED_DOMAINS, []),
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
isProduction: (env.NODE_ENV ?? "development") === "production"
};
-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 { unlink } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { dirname } from "node:path";
import { Database } from "bun:sqlite";
import { config } from "./config";
export type ProfileRow = {
email: string;
display_name: string;
avatar_path: string | null;
role: "admin" | "user";
totp_secret: string | null;
totp_enabled: number;
created_at: number;
updated_at: number;
};
type AdminRow = {
email: string;
password_hash: string;
@@ -22,31 +10,28 @@ type AdminRow = {
updated_at: number;
};
type ForwardingRow = {
email: string;
target_email: string | null;
enabled: number;
updated_at: number;
type SessionRow = {
token_hash: string;
admin_email: string;
expires_at: number;
created_at: number;
};
export type PasswordResetTokenRow = {
token_hash: string;
user_email: string;
created_by: string;
expires_at: number;
used_at: number | null;
created_at: number;
};
mkdirSync(dirname(config.dbPath), { recursive: true });
mkdirSync(config.avatarDir, { recursive: true });
const db = new Database(config.dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS user_profiles (
email TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_path TEXT,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_admins (
email TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
@@ -54,111 +39,140 @@ db.exec(`
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS forwarding_rules (
email TEXT PRIMARY KEY,
target_email TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS app_sessions (
token_hash TEXT PRIMARY KEY,
admin_email TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_email TEXT NOT NULL,
created_by TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
ON app_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_email
ON password_reset_tokens(user_email);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
ON password_reset_tokens(expires_at);
`);
const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email;
const toAbsoluteAvatarPath = (avatarPath: string | null): string | null =>
avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null;
const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1");
const insertProfileStatement = db.query(`
INSERT OR IGNORE INTO user_profiles (
email,
display_name,
avatar_path,
role,
totp_secret,
totp_enabled,
created_at,
updated_at
) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4)
`);
const updateProfileStatement = db.query(`
UPDATE user_profiles
SET display_name = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateAvatarStatement = db.query(`
UPDATE user_profiles
SET avatar_path = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateTotpStatement = db.query(`
UPDATE user_profiles
SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3
WHERE email = ?4
`);
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
const upsertAdminStatement = db.query(`
INSERT INTO app_admins (email, password_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
ON CONFLICT(email) DO UPDATE SET
password_hash = excluded.password_hash,
updated_at = excluded.updated_at
`);
const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
const upsertForwardingStatement = db.query(`
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
const insertSessionStatement = db.query(`
INSERT INTO app_sessions (token_hash, admin_email, expires_at, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at
`);
const getSessionStatement = db.query("SELECT * FROM app_sessions WHERE token_hash = ?1");
const deleteSessionStatement = db.query("DELETE FROM app_sessions WHERE token_hash = ?1");
const deleteExpiredSessionsStatement = db.query("DELETE FROM app_sessions WHERE expires_at <= ?1");
const insertResetTokenStatement = db.query(`
INSERT INTO password_reset_tokens (token_hash, user_email, created_by, expires_at, used_at, created_at)
VALUES (?1, ?2, ?3, ?4, NULL, ?5)
`);
const deleteResetTokenStatement = db.query("DELETE FROM password_reset_tokens WHERE token_hash = ?1");
const deleteActiveResetTokensForUserStatement = db.query(`
DELETE FROM password_reset_tokens
WHERE user_email = ?1
AND used_at IS NULL
`);
const getActiveResetTokenStatement = db.query(`
SELECT * FROM password_reset_tokens
WHERE token_hash = ?1
AND used_at IS NULL
AND expires_at > ?2
`);
const claimResetTokenStatement = db.query(`
UPDATE password_reset_tokens
SET used_at = ?2
WHERE token_hash = ?1
AND used_at IS NULL
AND expires_at > ?3
`);
const releaseResetTokenStatement = db.query(`
UPDATE password_reset_tokens
SET used_at = NULL
WHERE token_hash = ?1
`);
const deleteExpiredResetTokensStatement = db.query(`
DELETE FROM password_reset_tokens
WHERE expires_at <= ?1
OR (used_at IS NOT NULL AND used_at <= ?2)
`);
export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null;
export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => {
const now = Date.now();
insertProfileStatement.run(email, defaultDisplayName(email), role, now);
return getProfile(email)!;
};
export const updateProfile = (email: string, displayName: string): ProfileRow => {
updateProfileStatement.run(displayName, Date.now(), email);
return getProfile(email)!;
};
export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => {
updateAvatarStatement.run(avatarPath, Date.now(), email);
return getProfile(email)!;
};
export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => {
updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email);
return getProfile(email)!;
};
export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null;
export const getAdmin = (email: string): AdminRow | null =>
(getAdminStatement.get(email) as AdminRow | null) ?? null;
export const upsertAdmin = (email: string, passwordHash: string): void => {
upsertAdminStatement.run(email, passwordHash, Date.now());
ensureProfile(email, "admin");
};
export const getForwardingRule = (email: string): ForwardingRow | null =>
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => {
const now = Date.now();
upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now);
return getForwardingRule(email)!;
upsertAdminStatement.run(email, passwordHash, now);
};
export const deleteAvatarFile = async (avatarPath: string | null): Promise<void> => {
if (!avatarPath) {
return;
}
const absolutePath = toAbsoluteAvatarPath(avatarPath);
if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) {
return;
}
try {
await unlink(absolutePath);
} catch {
// Best-effort cleanup of replaced avatars.
}
export const createSession = (tokenHash: string, adminEmail: string, expiresAt: number): void => {
insertSessionStatement.run(tokenHash, adminEmail, expiresAt, Date.now());
};
export const getSession = (tokenHash: string): SessionRow | null =>
(getSessionStatement.get(tokenHash) as SessionRow | null) ?? null;
export const deleteSession = (tokenHash: string): void => {
deleteSessionStatement.run(tokenHash);
};
export const createPasswordResetToken = (input: {
tokenHash: string;
userEmail: string;
createdBy: string;
expiresAt: number;
}): void => {
insertResetTokenStatement.run(
input.tokenHash,
input.userEmail,
input.createdBy,
input.expiresAt,
Date.now()
);
};
export const deletePasswordResetToken = (tokenHash: string): void => {
deleteResetTokenStatement.run(tokenHash);
};
export const deleteActivePasswordResetTokensForUser = (userEmail: string): void => {
deleteActiveResetTokensForUserStatement.run(userEmail);
};
export const getActivePasswordResetToken = (tokenHash: string): PasswordResetTokenRow | null =>
(getActiveResetTokenStatement.get(tokenHash, Date.now()) as PasswordResetTokenRow | null) ?? null;
export const claimPasswordResetToken = (tokenHash: string): boolean => {
const usedAt = Date.now();
const result = claimResetTokenStatement.run(tokenHash, usedAt, usedAt);
return result.changes > 0;
};
export const releasePasswordResetToken = (tokenHash: string): void => {
releaseResetTokenStatement.run(tokenHash);
};
export const cleanupDatabase = (): void => {
const now = Date.now();
deleteExpiredSessionsStatement.run(now);
deleteExpiredResetTokensStatement.run(now, now - 86_400_000);
};
+1 -9
View File
@@ -1,16 +1,8 @@
export class HttpError extends Error {
readonly status: number;
readonly expose: boolean;
readonly details?: unknown;
constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) {
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.expose = options?.expose ?? true;
this.details = options?.details;
}
}
export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError;
+87 -46
View File
@@ -1,77 +1,118 @@
import { ZodError, type ZodSchema } from "zod";
import { config } from "./config";
import { HttpError, isHttpError } from "./errors";
import { buildResponseHeaders } from "./security";
import { HttpError } from "./errors";
export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response =>
const allowedOrigin = (request: Request): string | null => {
const origin = request.headers.get("origin");
if (!origin) {
return null;
}
return config.appOrigins.includes(origin.toLowerCase()) ? origin : null;
};
const securityHeaders = (): Record<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), {
status,
headers: buildResponseHeaders(request, {
"Content-Type": "application/json; charset=utf-8",
...init
headers: mergeHeaders(request, {
"content-type": "application/json; charset=utf-8",
...headers
})
});
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response =>
export const emptyResponse = (request: Request, status = 204, headers?: HeadersInit): Response =>
new Response(null, {
status,
headers: buildResponseHeaders(request, init)
headers: mergeHeaders(request, headers)
});
export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response =>
new Response(body as BodyInit, {
status: 200,
headers: buildResponseHeaders(request, {
"Content-Type": contentType,
"Cache-Control": "private, max-age=300",
...init
})
});
export const optionsResponse = (request: Request): Response =>
emptyResponse(request, 204);
export const parseJson = async <T>(request: Request, schema: { parse(data: unknown): T }): Promise<T> => {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
throw new HttpError(415, "Expected application/json request body.");
}
export const parseJson = async <T>(request: Request, schema: ZodSchema<T>): Promise<T> => {
let body: unknown;
let payload: unknown;
try {
payload = await request.json();
body = await request.json();
} catch {
throw new HttpError(400, "Malformed JSON request body.");
throw new HttpError(400, "Request body must be valid JSON.");
}
try {
return schema.parse(payload);
return schema.parse(body);
} catch (error) {
throw new HttpError(400, "Request validation failed.", { details: error });
if (error instanceof ZodError) {
throw new HttpError(400, error.issues[0]?.message ?? "Invalid request body.");
}
throw error;
}
};
export const errorResponse = (request: Request, error: unknown): Response => {
if (isHttpError(error)) {
const retryAfterMs =
typeof error.details === "object" &&
error.details !== null &&
"retryAfterMs" in error.details &&
typeof (error.details as { retryAfterMs?: unknown }).retryAfterMs === "number"
? Math.ceil(((error.details as { retryAfterMs: number }).retryAfterMs ?? 0) / 1000)
: undefined;
if (error instanceof HttpError) {
return jsonResponse(request, error.status, {
error: {
message: error.message
}
});
}
return jsonResponse(
request,
error.status,
{
error: error.expose ? error.message : "An internal server error occurred."
},
retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined
);
if (error instanceof ZodError) {
return jsonResponse(request, 400, {
error: {
message: error.issues[0]?.message ?? "Invalid request."
}
});
}
if (config.nodeEnv !== "production") {
console.error(error);
}
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 { HttpError } from "./errors";
import { createHash, randomBytes } from "node:crypto";
type RateLimitResult = {
allowed: boolean;
retryAfterMs: number;
export const hashToken = (value: string): string =>
createHash("sha256").update(value).digest("hex");
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 = {
count: number;
resetAt: number;
export const buildClearedCookieValue = (name: string, secure: boolean): string =>
buildCookieValue(name, "", { maxAgeSeconds: 0, secure });
export const readCookie = (request: Request, name: string): string | null => {
const raw = request.headers.get("cookie");
if (!raw) {
return null;
}
for (const part of raw.split(";")) {
const [cookieName, ...rest] = part.trim().split("=");
if (cookieName === name) {
return decodeURIComponent(rest.join("="));
}
}
return null;
};
export class SlidingWindowLimiter {
private readonly buckets = new Map<string, RateBucket>();
export const sanitizeHeaderValue = (value: string): string =>
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 }
});
}
};
+34 -84
View File
@@ -1,105 +1,55 @@
import { z } from "zod";
import { config } from "./config";
import { HttpError } from "./errors";
const emailRegex =
/^(?=.{1,254}$)(?=.{1,64}@)[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/i;
export const strictEmailSchema = z
const baseEmailSchema = z
.string()
.trim()
.toLowerCase()
.regex(emailRegex, "Invalid email address.")
.refine((value) => !value.includes(".."), "Invalid email address.");
.email("Enter a valid email address.");
export const hostedMailboxSchema = strictEmailSchema.refine((email) => {
const domain = email.split("@")[1] ?? "";
return config.hostedDomains.includes(domain);
}, "Mailbox domain is not hosted by this server.");
const hostedDomainSet = new Set(config.hostedDomains);
export const hostedEmailSchema = baseEmailSchema.superRefine((value, ctx) => {
const domain = value.split("@")[1] ?? "";
if (!hostedDomainSet.has(domain)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "The email domain is not managed by this Docker Mailserver."
});
}
});
export const passwordSchema = z
.string()
.min(12, "Password must contain at least 12 characters.")
.max(128, "Password is too long.");
export const loginSchema = z.object({
email: strictEmailSchema,
password: z.string().min(8).max(256),
totpCode: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional()
email: baseEmailSchema,
password: z.string().min(1, "Password is required.")
});
export const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80)
export const createUserSchema = z.object({
email: hostedEmailSchema
});
export const composeSchema = z.object({
to: z.array(strictEmailSchema).min(1).max(10),
subject: z.string().trim().max(160),
textBody: z.string().trim().min(1).max(20_000)
export const updatePasswordSchema = z.object({
password: passwordSchema
});
export const forwardingSchema = z
.object({
enabled: z.boolean(),
target: strictEmailSchema.nullable()
})
.superRefine((value, ctx) => {
if (value.enabled && !value.target) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Forwarding target is required." });
return;
}
});
export const totpCodeSchema = z.object({
code: z.string().trim().regex(/^\d{6}$/)
export const resetTokenSchema = z.object({
token: z.string().trim().min(32, "Invalid reset token.")
});
export const messageIdSchema = z.object({
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/)
export const resetPasswordSchema = z.object({
token: z.string().trim().min(32, "Invalid reset token."),
password: passwordSchema
});
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => {
if (!target) {
return;
}
export const routeEmailSchema = z.object({
email: hostedEmailSchema
});
if (target === mailboxEmail) {
throw new HttpError(400, "Forwarding to the same mailbox is not allowed.");
}
const domain = target.split("@")[1] ?? "";
if (!config.forwardingAllowedDomains.includes(domain)) {
throw new HttpError(400, "Forwarding target domain is not allowed by policy.");
}
};
type ImageKind = {
ext: string;
mime: string;
};
export const detectImageKind = (bytes: Uint8Array): ImageKind | null => {
if (bytes.byteLength < 12) {
return null;
}
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return { ext: "png", mime: "image/png" };
}
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return { ext: "jpg", mime: "image/jpeg" };
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
return { ext: "gif", mime: "image/gif" };
}
const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
if (riff === "RIFF" && webp === "WEBP") {
return { ext: "webp", mime: "image/webp" };
}
return null;
};
export const parseRouteEmail = (value: string): string =>
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
+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 { HttpError } from "../lib/errors";
import { strictEmailSchema } from "../lib/validation";
import { getSupportedFolder, listInboxFiles, readMailboxFile, sendMessage } from "../docker/mailserver";
import { sanitizeHeaderValue } from "../lib/security";
import { sendMail } from "../docker/mailserver";
const parser = new PostalMime();
export const sendPasswordResetEmail = async (email: string, resetLink: string, expiresAt: string): Promise<void> => {
const subject = sanitizeHeaderValue("Set your mailbox password");
const from = sanitizeHeaderValue(config.appMailFrom);
const to = sanitizeHeaderValue(email);
const createMessageId = (containerPath: string): string =>
createHash("sha256").update(containerPath).digest("hex").slice(0, 32);
const toText = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
const addressFieldToString = (value: unknown): string => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
return value.map((entry) => addressFieldToString(entry)).filter(Boolean).join(", ");
}
if (value && typeof value === "object") {
const record = value as Record<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",
const message = [
`From: Mail Admin <${from}>`,
`To: <${to}>`,
`Subject: ${subject}`,
"Content-Type: text/plain; charset=utf-8",
"",
cleanText
].join("\r\n");
"A mailbox password reset was requested for your account.",
"",
`Use this one-time link to choose a new password: ${resetLink}`,
`This link expires at ${expiresAt}.`,
"",
"If you did not expect this message, contact your administrator."
].join("\n");
await sendMessage(normalizedSender, cleanRecipients, rawMessage);
await sendMail(message);
};
export const getMailboxFolderLabel = (): string => getSupportedFolder();
-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",
"compilerOptions": {
"lib": [
"ES2022",
"DOM"
]
},
"include": [
"src"
]
}
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"rootDir": "src"
"jsx": "react-jsx"
},
"include": [
"src",
+67 -115
View File
@@ -10,15 +10,11 @@
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0",
"html-to-text": "^9.0.5",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2",
"elysia": "^1.3.10",
"zod": "^3.25.0",
},
"devDependencies": {
"bun-types": "^1.3.11",
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
},
},
@@ -27,23 +23,23 @@
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"bun-types": "^1.3.11",
"@vitejs/plugin-react": "^5.0.0",
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
"vite": "^6.2.4",
"vite": "^7.0.0",
},
},
"packages/shared": {
"name": "@internal-mail/shared",
"version": "0.0.1",
"devDependencies": {
"bun-types": "^1.3.11",
"bun-types": "^1.3.10",
"typescript": "^5.8.3",
},
},
@@ -89,57 +85,57 @@
"@borewit/text-codec": ["@borewit/[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"],
@@ -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=="],
"@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/[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=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@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=="],
"@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=="],
@@ -243,7 +235,7 @@
"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=="],
@@ -253,28 +245,14 @@
"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=="],
"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.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"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=="],
"fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -287,22 +265,14 @@
"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=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leac": ["[email protected]", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
@@ -315,36 +285,22 @@
"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=="],
"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=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["[email protected]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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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 {
listen 80;
server_name mail.example.com;
return 301 https://$host$request_uri;
server_name _;
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",
"private": true,
"packageManager": "[email protected]1",
"packageManager": "[email protected]0",
"workspaces": [
"apps/*",
"packages/*"
+1 -2
View File
@@ -10,8 +10,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"bun-types": "^1.3.11",
"bun-types": "^1.3.10",
"typescript": "^5.8.3"
}
}
+54 -51
View File
@@ -1,60 +1,63 @@
export type UserRole = "admin" | "user";
export interface SessionUser {
email: string;
displayName: string;
role: UserRole;
avatarUrl: string | null;
totpEnabled: boolean;
}
export interface SessionView {
export type AdminSession = {
authenticated: boolean;
csrfToken: string | null;
user: SessionUser | null;
}
adminEmail: string | null;
};
export interface ForwardingRule {
enabled: boolean;
target: string | null;
allowedDomains: string[];
}
export type SessionResponse = {
session: AdminSession;
};
export interface ProfileSettings {
export type MailUser = {
email: string;
displayName: string;
role: UserRole;
avatarUrl: string | null;
totpEnabled: boolean;
}
localPart: string;
domain: string;
};
export interface MailAttachment {
filename: string;
mimeType: string;
size: number | null;
}
export type UsersResponse = {
items: MailUser[];
};
export interface MailMessageSummary {
id: string;
from: string;
subject: string;
preview: string;
receivedAt: string;
hasHtml: boolean;
}
export type CreateUserRequest = {
email: string;
};
export interface MailMessageDetail extends MailMessageSummary {
to: string[];
textBody: string;
safeHtml: string | null;
attachments: MailAttachment[];
}
export type CreateUserResponse = {
user: MailUser;
resetLinkSent: boolean;
resetLinkExpiresAt: string | null;
warning: string | null;
};
export interface LoginResponse {
session: SessionView;
}
export type UpdateUserPasswordRequest = {
password: string;
};
export type LoginRequest = {
email: string;
password: string;
};
export type PasswordResetTriggerResponse = {
expiresAt: string;
};
export type PasswordResetInspectRequest = {
token: string;
};
export type PasswordResetInspectResponse = {
emailHint: string;
expiresAt: string;
};
export type PasswordResetConsumeRequest = {
token: string;
password: string;
};
export type ApiErrorBody = {
error: {
message: string;
};
};
export interface TotpSetupResponse {
secret: string;
otpauthUrl: string;
}
+2 -3
View File
@@ -2,11 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"emitDeclarationOnly": true
"rootDir": "src",
"noEmit": false
},
"include": [
"src"
]
}
-2
View File
@@ -7,7 +7,6 @@
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"types": [
@@ -21,4 +20,3 @@
}
}
}