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