This commit is contained in:
Anaïs Saraiva
2026-03-29 18:45:25 +02:00
parent 0f7d692d12
commit afc839d43d
42 changed files with 3809 additions and 58 deletions
View File
+23
View File
@@ -0,0 +1,23 @@
NODE_ENV=development
APP_HOST=0.0.0.0
APP_PORT=3001
APP_ORIGINS=http://localhost:5173
APP_EXTERNAL_BASE_URL=https://mail.example.com
MAILSERVER_CONTAINER=mailserver
MAILSERVER_TIMEOUT_MS=10000
HOSTED_DOMAINS=example.com
FORWARDING_ALLOWED_DOMAINS=example.com
SESSION_TTL_MINUTES=15
GLOBAL_RATE_LIMIT=240
ROUTE_RATE_LIMIT=90
RATE_LIMIT_WINDOW_SECONDS=60
LOGIN_RATE_LIMIT=5
LOGIN_WINDOW_SECONDS=600
MAX_MESSAGES=25
AVATAR_MAX_BYTES=2097152
TRUST_PROXY=false
APP_ADMIN_EMAIL=[email protected]
APP_ADMIN_PASSWORD=change-me-now
# 32-byte base64 key used to encrypt TOTP secrets at rest.
TOTP_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY
+11
View File
@@ -0,0 +1,11 @@
node_modules
.DS_Store
dist
build
.env
.env.local
data/app.db
data/avatars/*
!data/avatars/.gitkeep
coverage
+133 -58
View File
@@ -1,93 +1,168 @@
# internal-mail-server
# Secure Webmail for docker-mailserver
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.
## Stack
## Getting started
- Backend: Bun + ElysiaJS
- Frontend: React + Vite running on Bun
- Storage: Bun SQLite for app metadata, sessions, user preferences, and encrypted TOTP secrets
- Mail integration: Docker CLI with fixed argument arrays only
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
## Security-first design
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
### Critical risks and mitigations
## Add your files
| Risk | Mitigation |
| --- | --- |
| Command injection through Docker CLI | All Docker calls use `Bun.spawn()` with explicit argument arrays. No shell string concatenation, no `sh -c`, no raw user input in command strings. |
| Session theft or token leakage | Server-side opaque sessions in `httpOnly`, `SameSite=Strict` cookies. The frontend never stores session identifiers in `localStorage`. |
| CSRF | Every state-changing route requires an in-memory CSRF token in `X-CSRF-Token` plus the authenticated cookie. |
| XSS from HTML email | HTML email is sanitized on the backend, dangerous tags are removed, remote images are not allowed, and the frontend renders sanitized HTML only in a sandboxed iframe. |
| Brute-force login | Dedicated login rate limiter plus a global limiter. |
| Unsafe file uploads | Avatar uploads are size-limited, magic-byte checked, renamed with random tokens, stored outside the public root, and served through an authenticated endpoint. |
| Forwarding abuse | Forwarding targets are strictly validated, self-forwarding is blocked, and allowed target domains are policy-controlled. |
| Secret exposure | No passwords or TOTP secrets are logged. TOTP secrets are encrypted at rest with AES-GCM via `TOTP_ENCRYPTION_KEY`. |
| Information leakage | API errors return generic messages for internal failures and use explicit HTTP status codes for validation and auth issues. |
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
### Why server-side sessions instead of JWTs
```
cd existing_repo
git remote add origin https://gitlab.com/ihrz/internal-mail-server.git
git branch -M main
git push -uf origin main
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.
## Features
- Mailbox login against docker-mailserver via `doveadm auth test`
- Optional seeded local admin account with Argon2id-hashed password
- Inbox listing and message preview from Maildir inside the mail container
- Plain-text compose flow using `sendmail` inside the container
- Safe HTML email preview
- Profile settings, avatar upload, and forwarding management
- Optional TOTP two-factor authentication
- Security headers and restricted CORS
## Project layout
```text
apps/
api/ Elysia backend
web/ React/Vite frontend
packages/
shared/ Shared TypeScript types
infra/
nginx/ Reverse proxy example with security headers
data/
avatars/ Stored outside the public web root
```
## Integrate with your tools
## Local development
* [Set up project integrations](https://gitlab.com/ihrz/internal-mail-server/-/settings/integrations)
### 1. Configure environment
## Collaborate with your team
Copy `.env.example` to `.env` and update the values:
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
- `HOSTED_DOMAINS`: domains served by docker-mailserver
- `MAILSERVER_CONTAINER`: container name to run `docker exec` against
- `APP_ORIGINS`: allowed frontend origins
- `TOTP_ENCRYPTION_KEY`: base64-encoded 32-byte key
- `APP_ADMIN_EMAIL` / `APP_ADMIN_PASSWORD`: optional local admin bootstrap account
## Test and Deploy
Generate a 32-byte base64 key:
Use the built-in continuous integration in GitLab.
```bash
openssl rand -base64 32
```
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
### 2. Install dependencies
***
If Bun needs an explicit temp dir on your machine:
# Editing this README
```bash
BUN_TMPDIR=/tmp bun install
```
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
### 3. Start the apps
## Suggestions for a good README
Backend:
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
```bash
bun run --cwd apps/api dev
```
## Name
Choose a self-explaining name for your project.
Frontend:
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
```bash
bun run --cwd apps/web dev
```
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
The Vite dev server proxies `/api` to `http://localhost:3001`.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Docker CLI assumptions
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
The backend assumes docker-mailserver is available in a running container and that the following commands are present:
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
- `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
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
The mailbox reader currently targets the standard Maildir layout:
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
```text
/var/mail/<domain>/<local-part>/Maildir
```
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
If your container layout differs, update `apps/api/src/docker/mailserver.ts`.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
## Production deployment guidance
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
### Reverse proxy
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
Use a reverse proxy such as Nginx or Traefik in front of both the frontend and API.
## License
For open source projects, say how it is licensed.
- 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
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
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.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@internal-mail/api",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --target bun --outdir dist",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0",
"html-to-text": "^9.0.5",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2"
},
"devDependencies": {
"bun-types": "^1.3.11",
"typescript": "^5.8.3"
}
}
+248
View File
@@ -0,0 +1,248 @@
import * as OTPAuth from "otpauth";
import type { SessionView, UserRole } from "@internal-mail/shared";
import { authenticateMailbox } from "../docker/mailserver";
import { config } from "../lib/config";
import { decryptAtRest, encryptAtRest, randomToken, sha256Hex } from "../lib/crypto";
import {
createSession,
deleteExpiredSessions,
deleteSession,
ensureProfile,
getAdmin,
getProfile,
getSession,
setTotpSecret,
touchSession,
upsertAdmin
} from "../lib/db";
import { HttpError } from "../lib/errors";
import { assertCsrf, getClientIp, parseCookies, serializeExpiredCookie, serializeSessionCookie } from "../lib/security";
import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation";
type ActiveSession = {
id: string;
csrfToken: string;
email: string;
role: UserRole;
session: SessionView;
};
const issuer = "Secure Webmail";
const createTotp = (email: string, secret: string): OTPAuth.TOTP =>
new OTPAuth.TOTP({
issuer,
label: email,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret)
});
const buildSessionView = (email: string, role: UserRole, csrfToken: string): SessionView => {
const profile = ensureProfile(email, role);
return {
authenticated: true,
csrfToken,
user: {
email: profile.email,
displayName: profile.display_name,
role,
avatarUrl: profile.avatar_path ? `/api/settings/avatar/${encodeURIComponent(profile.email)}` : null,
totpEnabled: Boolean(profile.totp_enabled)
}
};
};
const createSessionRecord = async (email: string, role: UserRole, request: Request): Promise<ActiveSession> => {
const id = randomToken(32);
const csrfToken = randomToken(24);
const now = Date.now();
const userAgentHash = await sha256Hex(request.headers.get("user-agent") ?? "unknown");
const ipHash = await sha256Hex(getClientIp(request));
createSession({
id,
email,
role,
csrf_token: csrfToken,
expires_at: now + config.sessionTtlMs,
created_at: now,
last_seen_at: now,
user_agent_hash: userAgentHash,
ip_hash: ipHash
});
return {
id,
csrfToken,
email,
role,
session: buildSessionView(email, role, csrfToken)
};
};
export const seedAdminAccount = async (): Promise<void> => {
if (!config.adminEmail || !config.adminPassword) {
return;
}
const existing = getAdmin(config.adminEmail);
if (existing && (await Bun.password.verify(config.adminPassword, existing.password_hash))) {
ensureProfile(config.adminEmail, "admin");
return;
}
const passwordHash = await Bun.password.hash(config.adminPassword, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2
});
upsertAdmin(config.adminEmail, passwordHash);
};
export const login = async (
email: string,
password: string,
totpCode: string | undefined,
request: Request
): Promise<{ cookie: string; session: SessionView }> => {
const normalizedEmail = strictEmailSchema.parse(email);
let role: UserRole = "user";
const localAdmin = getAdmin(normalizedEmail);
if (localAdmin) {
const valid = await Bun.password.verify(password, localAdmin.password_hash);
if (!valid) {
throw new HttpError(401, "Invalid email or password.");
}
role = "admin";
} else {
hostedMailboxSchema.parse(normalizedEmail);
const isValid = await authenticateMailbox(normalizedEmail, password);
if (!isValid) {
throw new HttpError(401, "Invalid email or password.");
}
}
const profile = ensureProfile(normalizedEmail, role);
if (profile.totp_enabled) {
if (!totpCode) {
throw new HttpError(401, "Two-factor code is required.");
}
const decryptedSecret = await decryptAtRest(profile.totp_secret!);
const delta = createTotp(normalizedEmail, decryptedSecret).validate({ token: totpCode, window: 1 });
if (delta === null) {
throw new HttpError(401, "Invalid two-factor code.");
}
}
const activeSession = await createSessionRecord(normalizedEmail, role, request);
return {
cookie: serializeSessionCookie(activeSession.id),
session: activeSession.session
};
};
export const getSessionFromRequest = async (request: Request, options?: { requireCsrf?: boolean; requireAdmin?: boolean }) => {
deleteExpiredSessions();
const cookies = parseCookies(request.headers.get("cookie"));
const sessionId = cookies[config.cookieName];
if (!sessionId) {
throw new HttpError(401, "Authentication is required.");
}
const row = getSession(sessionId);
if (!row || row.expires_at <= Date.now()) {
if (row) {
deleteSession(sessionId);
}
throw new HttpError(401, "Session expired. Please sign in again.");
}
const userAgentHash = await sha256Hex(request.headers.get("user-agent") ?? "unknown");
if (userAgentHash !== row.user_agent_hash) {
deleteSession(sessionId);
throw new HttpError(401, "Session validation failed.");
}
if (options?.requireCsrf) {
assertCsrf(request, row.csrf_token);
}
if (options?.requireAdmin && row.role !== "admin") {
throw new HttpError(403, "Administrator access is required.");
}
const ipHash = await sha256Hex(getClientIp(request));
touchSession(sessionId, Date.now() + config.sessionTtlMs, ipHash);
return {
id: row.id,
email: row.email,
role: row.role,
csrfToken: row.csrf_token,
session: buildSessionView(row.email, row.role, row.csrf_token)
};
};
export const getAnonymousSession = (): SessionView => ({
authenticated: false,
csrfToken: null,
user: null
});
export const logout = (request: Request): string => {
const cookies = parseCookies(request.headers.get("cookie"));
const sessionId = cookies[config.cookieName];
if (sessionId) {
deleteSession(sessionId);
}
return serializeExpiredCookie();
};
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);
};
+88
View File
@@ -0,0 +1,88 @@
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
import { hostedMailboxSchema } from "../lib/validation";
import { runDocker } from "./runner";
const MAILDIR_FOLDER = "INBOX";
const localPartRegex = /^[A-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}$/i;
const splitHostedEmail = (email: string): { localPart: string; domain: string } => {
const normalized = hostedMailboxSchema.parse(email);
const [localPart, domain] = normalized.split("@");
if (!localPart || !domain || !localPartRegex.test(localPart)) {
throw new HttpError(400, "Unsupported mailbox format.");
}
return { localPart, domain };
};
const buildMaildirRoot = (email: string): string => {
const { localPart, domain } = splitHostedEmail(email);
return `/var/mail/${domain}/${localPart}/Maildir`;
};
export const authenticateMailbox = async (email: string, password: string): Promise<boolean> => {
const normalizedEmail = hostedMailboxSchema.parse(email);
const result = await runDocker(
["exec", config.mailserverContainer, "doveadm", "auth", "test", normalizedEmail, password],
{ allowNonZero: true }
);
return result.exitCode === 0;
};
export const listInboxFiles = async (email: string): Promise<string[]> => {
const root = buildMaildirRoot(email);
const result = await runDocker([
"exec",
config.mailserverContainer,
"find",
`${root}/cur`,
`${root}/new`,
"-maxdepth",
"1",
"-type",
"f"
]);
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort()
.reverse();
};
export const readMailboxFile = async (containerPath: string): Promise<string> => {
if (!containerPath.startsWith("/var/mail/")) {
throw new HttpError(400, "Invalid mail file path.");
}
const result = await runDocker(["exec", config.mailserverContainer, "cat", containerPath]);
return result.stdout;
};
export const 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;
+69
View File
@@ -0,0 +1,69 @@
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
type DockerRunOptions = {
stdinText?: string;
allowNonZero?: boolean;
};
export type DockerRunResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export class DockerCommandError extends Error {
readonly exitCode: number;
readonly stderr: string;
constructor(message: string, exitCode: number, stderr: string) {
super(message);
this.name = "DockerCommandError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
export const runDocker = async (args: string[], options?: DockerRunOptions): Promise<DockerRunResult> => {
const process = Bun.spawn({
cmd: ["docker", ...args],
stdout: "pipe",
stderr: "pipe",
stdin: options?.stdinText ? "pipe" : "ignore",
env: {}
});
const timeout = setTimeout(() => {
process.kill();
}, config.mailserverTimeoutMs);
try {
if (options?.stdinText && process.stdin) {
await process.stdin.write(new TextEncoder().encode(options.stdinText));
await process.stdin.end();
}
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
if (exitCode !== 0 && !options?.allowNonZero) {
throw new DockerCommandError("Docker command failed.", exitCode, stderr);
}
return { stdout, stderr, exitCode };
} catch (error) {
if (error instanceof DockerCommandError) {
throw error;
}
throw new HttpError(504, "Docker command timed out or failed to execute.", {
expose: false
});
} finally {
clearTimeout(timeout);
}
};
+216
View File
@@ -0,0 +1,216 @@
import { Elysia } from "elysia";
import { login, logout, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service";
import { HttpError } from "./lib/errors";
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
import { config } from "./lib/config";
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security";
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation";
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service";
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service";
const app = new Elysia();
const route = (handler: (request: Request, params?: Record<string, string>) => Promise<Response> | Response) =>
async ({ request, params }: { request: Request; params: Record<string, string> }) => {
try {
assertAllowedOrigin(request);
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs);
return await handler(request, params);
} catch (error) {
return errorResponse(request, error);
}
};
await seedAdminAccount();
app.onRequest(({ request }) => {
if (request.method === "OPTIONS") {
return emptyResponse(request, 204);
}
});
app.get(
"/api/health",
route(async (request) =>
jsonResponse(request, 200, {
ok: true,
mode: config.nodeEnv,
folder: getMailboxFolderLabel()
})
)
);
app.get(
"/api/auth/session",
route(async (request) => {
try {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, { session: session.session });
} catch {
return jsonResponse(request, 200, { session: getAnonymousSession() });
}
})
);
app.post(
"/api/auth/login",
route(async (request) => {
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
const body = await parseJson(request, loginSchema);
const result = await login(body.email, body.password, body.totpCode, request);
return jsonResponse(
request,
200,
{ session: result.session },
{
"Set-Cookie": result.cookie
}
);
})
);
app.post(
"/api/auth/logout",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const expiredCookie = logout(request);
return jsonResponse(
request,
200,
{ session: getAnonymousSession(), email: session.email },
{ "Set-Cookie": expiredCookie }
);
})
);
app.post(
"/api/auth/totp/setup",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
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, { requireCsrf: true });
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, { requireCsrf: true });
const body = await parseJson(request, totpCodeSchema);
await disableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
);
app.get(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getProfileSettings(session.email));
})
);
app.patch(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const body = await parseJson(request, profileSchema);
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
})
);
app.post(
"/api/settings/avatar",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const formData = await request.formData();
const avatar = formData.get("avatar");
if (!(avatar instanceof File)) {
throw new HttpError(400, "Avatar upload is required.");
}
const profile = await saveAvatar(session.email, avatar);
return jsonResponse(request, 200, profile);
})
);
app.get(
"/api/settings/avatar/:email",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
const targetEmail = strictEmailSchema.parse(params?.email ?? "");
if (targetEmail !== session.email && session.role !== "admin") {
throw new HttpError(403, "You can only access your own avatar.");
}
const file = getAvatarFile(targetEmail);
return fileResponse(request, file, file.type || "application/octet-stream");
})
);
app.get(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getForwardingSettings(session.email));
})
);
app.put(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
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(
"/api/mail/send",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
const body = await parseJson(request, composeSchema);
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
return emptyResponse(request, 204);
})
);
app.listen({
hostname: config.host,
port: config.port
});
console.log(`Secure webmail API listening on http://${config.host}:${config.port}`);
+72
View File
@@ -0,0 +1,72 @@
import { resolve } from "node:path";
import { z } from "zod";
const splitList = (value: string | undefined, fallback: string): string[] =>
(value ?? fallback)
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
APP_HOST: z.string().default("0.0.0.0"),
APP_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
APP_ORIGINS: z.string().default("http://localhost:5173"),
APP_EXTERNAL_BASE_URL: z.string().url().default("http://localhost:5173"),
DATA_DIR: z.string().default("./data"),
MAILSERVER_CONTAINER: z
.string()
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/)
.default("mailserver"),
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
HOSTED_DOMAINS: z.string().default("example.com"),
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(15),
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()
});
const raw = envSchema.parse(Bun.env);
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 = {
nodeEnv: raw.NODE_ENV,
host: raw.APP_HOST,
port: raw.APP_PORT,
externalBaseUrl: raw.APP_EXTERNAL_BASE_URL,
allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")),
dataDir: resolve(process.cwd(), raw.DATA_DIR),
avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"),
dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"),
mailserverContainer: raw.MAILSERVER_CONTAINER,
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
sessionTtlMs: raw.SESSION_TTL_MINUTES * 60_000,
cookieName: raw.NODE_ENV === "production" ? "__Host-webmail_session" : "webmail_session",
secureCookies: raw.NODE_ENV === "production",
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
routeRateLimit: raw.ROUTE_RATE_LIMIT,
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
loginRateLimit: raw.LOGIN_RATE_LIMIT,
loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000,
maxMessages: raw.MAX_MESSAGES,
avatarMaxBytes: raw.AVATAR_MAX_BYTES,
trustProxy: raw.TRUST_PROXY === "true",
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
adminPassword: raw.APP_ADMIN_PASSWORD ?? null,
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null
} as const;
+60
View File
@@ -0,0 +1,60 @@
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");
};
+228
View File
@@ -0,0 +1,228 @@
import { mkdirSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { Database } from "bun:sqlite";
import { config } from "./config";
export type ProfileRow = {
email: string;
display_name: string;
avatar_path: string | null;
role: "admin" | "user";
totp_secret: string | null;
totp_enabled: number;
created_at: number;
updated_at: number;
};
export type SessionRow = {
id: string;
email: string;
role: "admin" | "user";
csrf_token: string;
expires_at: number;
created_at: number;
last_seen_at: number;
user_agent_hash: string;
ip_hash: string;
};
type AdminRow = {
email: string;
password_hash: string;
created_at: number;
updated_at: number;
};
type ForwardingRow = {
email: string;
target_email: string | null;
enabled: number;
updated_at: number;
};
mkdirSync(dirname(config.dbPath), { recursive: true });
mkdirSync(config.avatarDir, { recursive: true });
const db = new Database(config.dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS user_profiles (
email TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_path TEXT,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_admins (
email TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
csrf_token TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
user_agent_hash TEXT NOT NULL,
ip_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS forwarding_rules (
email TEXT PRIMARY KEY,
target_email TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
`);
const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email;
const toAbsoluteAvatarPath = (avatarPath: string | null): string | null =>
avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null;
const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1");
const insertProfileStatement = db.query(`
INSERT OR IGNORE INTO user_profiles (
email,
display_name,
avatar_path,
role,
totp_secret,
totp_enabled,
created_at,
updated_at
) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4)
`);
const updateProfileStatement = db.query(`
UPDATE user_profiles
SET display_name = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateAvatarStatement = db.query(`
UPDATE user_profiles
SET avatar_path = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateTotpStatement = db.query(`
UPDATE user_profiles
SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3
WHERE email = ?4
`);
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
const upsertAdminStatement = db.query(`
INSERT INTO app_admins (email, password_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
`);
const insertSessionStatement = db.query(`
INSERT INTO sessions (id, email, role, csrf_token, expires_at, created_at, last_seen_at, user_agent_hash, ip_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8)
`);
const getSessionStatement = db.query("SELECT * FROM sessions WHERE id = ?1");
const touchSessionStatement = db.query(`
UPDATE sessions
SET expires_at = ?1, last_seen_at = ?2, ip_hash = ?3
WHERE id = ?4
`);
const deleteSessionStatement = db.query("DELETE FROM sessions WHERE id = ?1");
const deleteExpiredSessionsStatement = db.query("DELETE FROM sessions WHERE expires_at <= ?1");
const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
const upsertForwardingStatement = db.query(`
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at
`);
export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null;
export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => {
const now = Date.now();
insertProfileStatement.run(email, defaultDisplayName(email), role, now);
return getProfile(email)!;
};
export const updateProfile = (email: string, displayName: string): ProfileRow => {
updateProfileStatement.run(displayName, Date.now(), email);
return getProfile(email)!;
};
export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => {
updateAvatarStatement.run(avatarPath, Date.now(), email);
return getProfile(email)!;
};
export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => {
updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email);
return getProfile(email)!;
};
export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null;
export const upsertAdmin = (email: string, passwordHash: string): void => {
upsertAdminStatement.run(email, passwordHash, Date.now());
ensureProfile(email, "admin");
};
export const createSession = (session: SessionRow): void => {
insertSessionStatement.run(
session.id,
session.email,
session.role,
session.csrf_token,
session.expires_at,
session.created_at,
session.user_agent_hash,
session.ip_hash
);
};
export const getSession = (id: string): SessionRow | null => (getSessionStatement.get(id) as SessionRow | null) ?? null;
export const touchSession = (id: string, expiresAt: number, ipHash: string): void => {
touchSessionStatement.run(expiresAt, Date.now(), ipHash, id);
};
export const deleteSession = (id: string): void => {
deleteSessionStatement.run(id);
};
export const deleteExpiredSessions = (): void => {
deleteExpiredSessionsStatement.run(Date.now());
};
export const getForwardingRule = (email: string): ForwardingRow | null =>
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => {
const now = Date.now();
upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now);
return getForwardingRule(email)!;
};
export const deleteAvatarFile = async (avatarPath: string | null): Promise<void> => {
if (!avatarPath) {
return;
}
const absolutePath = toAbsoluteAvatarPath(avatarPath);
if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) {
return;
}
try {
await unlink(absolutePath);
} catch {
// Best-effort cleanup of replaced avatars.
}
};
+16
View File
@@ -0,0 +1,16 @@
export class HttpError extends Error {
readonly status: number;
readonly expose: boolean;
readonly details?: unknown;
constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) {
super(message);
this.name = "HttpError";
this.status = status;
this.expose = options?.expose ?? true;
this.details = options?.details;
}
}
export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError;
+77
View File
@@ -0,0 +1,77 @@
import { config } from "./config";
import { HttpError, isHttpError } from "./errors";
import { buildResponseHeaders } from "./security";
export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response =>
new Response(JSON.stringify(payload), {
status,
headers: buildResponseHeaders(request, {
"Content-Type": "application/json; charset=utf-8",
...init
})
});
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response =>
new Response(null, {
status,
headers: buildResponseHeaders(request, init)
});
export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response =>
new Response(body as BodyInit, {
status: 200,
headers: buildResponseHeaders(request, {
"Content-Type": contentType,
"Cache-Control": "private, max-age=300",
...init
})
});
export const parseJson = async <T>(request: Request, schema: { parse(data: unknown): T }): Promise<T> => {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
throw new HttpError(415, "Expected application/json request body.");
}
let payload: unknown;
try {
payload = await request.json();
} catch {
throw new HttpError(400, "Malformed JSON request body.");
}
try {
return schema.parse(payload);
} catch (error) {
throw new HttpError(400, "Request validation failed.", { details: error });
}
};
export const errorResponse = (request: Request, error: unknown): Response => {
if (isHttpError(error)) {
const retryAfterMs =
typeof error.details === "object" &&
error.details !== null &&
"retryAfterMs" in error.details &&
typeof (error.details as { retryAfterMs?: unknown }).retryAfterMs === "number"
? Math.ceil(((error.details as { retryAfterMs: number }).retryAfterMs ?? 0) / 1000)
: undefined;
return jsonResponse(
request,
error.status,
{
error: error.expose ? error.message : "An internal server error occurred."
},
retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined
);
}
if (config.nodeEnv !== "production") {
console.error(error);
}
return jsonResponse(request, 500, {
error: "An internal server error occurred."
});
};
+183
View File
@@ -0,0 +1,183 @@
import { config } from "./config";
import { HttpError } from "./errors";
type RateLimitResult = {
allowed: boolean;
retryAfterMs: number;
};
type RateBucket = {
count: number;
resetAt: number;
};
export class SlidingWindowLimiter {
private readonly buckets = new Map<string, RateBucket>();
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 parseCookies = (cookieHeader: string | null): Record<string, string> => {
if (!cookieHeader) {
return {};
}
return cookieHeader
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.reduce<Record<string, string>>((accumulator, entry) => {
const separatorIndex = entry.indexOf("=");
if (separatorIndex === -1) {
return accumulator;
}
const key = entry.slice(0, separatorIndex).trim();
const value = entry.slice(separatorIndex + 1).trim();
accumulator[key] = decodeURIComponent(value);
return accumulator;
}, {});
};
export const serializeSessionCookie = (value: string): string => {
const pieces = [
`${config.cookieName}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${Math.floor(config.sessionTtlMs / 1000)}`
];
if (config.secureCookies) {
pieces.push("Secure");
}
return pieces.join("; ");
};
export const serializeExpiredCookie = (): string => {
const pieces = [
`${config.cookieName}=`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"Max-Age=0"
];
if (config.secureCookies) {
pieces.push("Secure");
}
return pieces.join("; ");
};
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 assertCsrf = (request: Request, expectedToken: string): void => {
const csrfToken = request.headers.get("x-csrf-token");
if (!csrfToken || csrfToken !== expectedToken) {
throw new HttpError(403, "CSRF validation failed.");
}
};
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-Credentials", "true");
headers.set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token");
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
headers.append("Vary", "Origin");
}
headers.append("Vary", "Cookie");
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 }
});
}
};
+105
View File
@@ -0,0 +1,105 @@
import { z } from "zod";
import { config } from "./config";
import { HttpError } from "./errors";
const emailRegex =
/^(?=.{1,254}$)(?=.{1,64}@)[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/i;
export const strictEmailSchema = z
.string()
.trim()
.toLowerCase()
.regex(emailRegex, "Invalid email address.")
.refine((value) => !value.includes(".."), "Invalid email address.");
export const hostedMailboxSchema = strictEmailSchema.refine((email) => {
const domain = email.split("@")[1] ?? "";
return config.hostedDomains.includes(domain);
}, "Mailbox domain is not hosted by this server.");
export const loginSchema = z.object({
email: strictEmailSchema,
password: z.string().min(8).max(256),
totpCode: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional()
});
export const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80)
});
export const composeSchema = z.object({
to: z.array(strictEmailSchema).min(1).max(10),
subject: z.string().trim().max(160),
textBody: z.string().trim().min(1).max(20_000)
});
export const forwardingSchema = z
.object({
enabled: z.boolean(),
target: strictEmailSchema.nullable()
})
.superRefine((value, ctx) => {
if (value.enabled && !value.target) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Forwarding target is required." });
return;
}
});
export const totpCodeSchema = z.object({
code: z.string().trim().regex(/^\d{6}$/)
});
export const messageIdSchema = z.object({
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/)
});
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => {
if (!target) {
return;
}
if (target === mailboxEmail) {
throw new HttpError(400, "Forwarding to the same mailbox is not allowed.");
}
const domain = target.split("@")[1] ?? "";
if (!config.forwardingAllowedDomains.includes(domain)) {
throw new HttpError(400, "Forwarding target domain is not allowed by policy.");
}
};
type ImageKind = {
ext: string;
mime: string;
};
export const detectImageKind = (bytes: Uint8Array): ImageKind | null => {
if (bytes.byteLength < 12) {
return null;
}
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return { ext: "png", mime: "image/png" };
}
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return { ext: "jpg", mime: "image/jpeg" };
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
return { ext: "gif", mime: "image/gif" };
}
const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
if (riff === "RIFF" && webp === "WEBP") {
return { ext: "webp", mime: "image/webp" };
}
return null;
};
+201
View File
@@ -0,0 +1,201 @@
import { createHash } from "node:crypto";
import { convert as htmlToText } from "html-to-text";
import PostalMime from "postal-mime";
import sanitizeHtml from "sanitize-html";
import type { MailAttachment, MailMessageDetail, MailMessageSummary } from "@internal-mail/shared";
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
import { strictEmailSchema } from "../lib/validation";
import { getSupportedFolder, listInboxFiles, readMailboxFile, sendMessage } from "../docker/mailserver";
const parser = new PostalMime();
const createMessageId = (containerPath: string): string =>
createHash("sha256").update(containerPath).digest("hex").slice(0, 32);
const toText = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
const addressFieldToString = (value: unknown): string => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
return value.map((entry) => addressFieldToString(entry)).filter(Boolean).join(", ");
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const name = toText(record.name);
const address = toText(record.address);
const text = toText(record.text);
if (name && address) {
return `${name} <${address}>`;
}
return text || address;
}
return "";
};
const addressFieldToList = (value: unknown): string[] => {
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (Array.isArray(value)) {
return value.map((entry) => addressFieldToString(entry)).filter(Boolean);
}
if (value && typeof value === "object") {
const asText = addressFieldToString(value);
return asText ? [asText] : [];
}
return [];
};
const sanitizeEmailHtml = (html: string): string =>
sanitizeHtml(html, {
allowedTags: ["a", "b", "blockquote", "br", "code", "em", "i", "li", "ol", "p", "pre", "strong", "u", "ul"],
allowedAttributes: {
a: ["href", "title", "target", "rel"]
},
allowedSchemes: ["https", "mailto"],
transformTags: {
a: (_tagName: string, attribs: Record<string, string>) => ({
tagName: "a",
attribs: {
href: attribs.href ?? "#",
title: attribs.title ?? "",
target: "_blank",
rel: "noopener noreferrer nofollow"
}
})
}
});
const buildPreview = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, 160);
const toIsoString = (value: unknown): string => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString();
}
if (typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return new Date().toISOString();
};
const normalizeAttachments = (value: unknown): MailAttachment[] => {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => {
const record = entry as Record<string, unknown>;
return {
filename: toText(record.filename) || "attachment",
mimeType: toText(record.mimeType) || "application/octet-stream",
size: typeof record.size === "number" ? record.size : null
};
});
};
const parseMessage = async (containerPath: string, raw: string): Promise<MailMessageDetail> => {
const parsed = (await parser.parse(raw)) as Record<string, unknown>;
const html = typeof parsed.html === "string" ? parsed.html : null;
const safeHtml = html ? sanitizeEmailHtml(html) : null;
const textBody =
toText(parsed.text) ||
(safeHtml
? htmlToText(safeHtml, {
wordwrap: 120,
selectors: [{ selector: "a", options: { ignoreHref: false } }]
}).trim()
: "");
return {
id: createMessageId(containerPath),
from: addressFieldToString(parsed.from) || "Unknown sender",
to: addressFieldToList(parsed.to),
subject: toText(parsed.subject) || "(No subject)",
preview: buildPreview(textBody),
receivedAt: toIsoString(parsed.date),
textBody,
safeHtml,
hasHtml: Boolean(safeHtml),
attachments: normalizeAttachments(parsed.attachments)
};
};
export const listMessagesForMailbox = async (email: string): Promise<MailMessageSummary[]> => {
const files = await listInboxFiles(email);
const items = await Promise.all(
files.slice(0, config.maxMessages).map(async (path) => {
const raw = await readMailboxFile(path);
const parsed = await parseMessage(path, raw);
const summary: MailMessageSummary = {
id: parsed.id,
from: parsed.from,
subject: parsed.subject,
preview: parsed.preview,
receivedAt: parsed.receivedAt,
hasHtml: parsed.hasHtml
};
return summary;
})
);
return items.sort((left, right) => right.receivedAt.localeCompare(left.receivedAt));
};
export const getMessageForMailbox = async (email: string, id: string): Promise<MailMessageDetail> => {
const files = await listInboxFiles(email);
const match = files.find((path) => createMessageId(path) === id);
if (!match) {
throw new HttpError(404, "Message not found.");
}
const raw = await readMailboxFile(match);
return parseMessage(match, raw);
};
export const composeAndSendMessage = async (
sender: string,
recipients: string[],
subject: string,
textBody: string
): Promise<void> => {
const cleanRecipients = recipients.map((email) => strictEmailSchema.parse(email));
const normalizedSender = strictEmailSchema.parse(sender);
const cleanSubject = subject.replace(/[\r\n]+/g, " ").trim();
const cleanText = textBody.replace(/\r\n/g, "\n").trim();
const rawMessage = [
`From: ${normalizedSender}`,
`To: ${cleanRecipients.join(", ")}`,
`Subject: ${cleanSubject || "(No subject)"}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <${crypto.randomUUID()}@${normalizedSender.split("@")[1]}>`,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
cleanText
].join("\r\n");
await sendMessage(normalizedSender, cleanRecipients, rawMessage);
};
export const getMailboxFolderLabel = (): string => getSupportedFolder();
+107
View File
@@ -0,0 +1,107 @@
import { join } from "node:path";
import type { ForwardingRule, ProfileSettings } from "@internal-mail/shared";
import { config } from "../lib/config";
import {
deleteAvatarFile,
ensureProfile,
getForwardingRule,
getProfile,
setAvatarPath,
updateProfile,
upsertForwardingRule
} from "../lib/db";
import { HttpError } from "../lib/errors";
import { randomToken } from "../lib/crypto";
import { detectImageKind, ensureForwardingTargetAllowed } from "../lib/validation";
import { addForwardingAlias, removeForwardingAlias } from "../docker/mailserver";
const toAvatarUrl = (email: string, avatarPath: string | null): string | null =>
avatarPath ? `/api/settings/avatar/${encodeURIComponent(email)}` : null;
export const getProfileSettings = (email: string): ProfileSettings => {
const profile = ensureProfile(email);
return {
email: profile.email,
displayName: profile.display_name,
role: profile.role,
avatarUrl: toAvatarUrl(profile.email, profile.avatar_path),
totpEnabled: Boolean(profile.totp_enabled)
};
};
export const updateDisplayName = (email: string, displayName: string): ProfileSettings => {
const profile = updateProfile(email, displayName);
return {
email: profile.email,
displayName: profile.display_name,
role: profile.role,
avatarUrl: toAvatarUrl(profile.email, profile.avatar_path),
totpEnabled: Boolean(profile.totp_enabled)
};
};
export const saveAvatar = async (email: string, file: File): Promise<ProfileSettings> => {
if (file.size > config.avatarMaxBytes) {
throw new HttpError(413, "Avatar exceeds the configured size limit.");
}
const bytes = new Uint8Array(await file.arrayBuffer());
const imageKind = detectImageKind(bytes);
if (!imageKind) {
throw new HttpError(400, "Only PNG, JPEG, GIF, and WebP avatars are allowed.");
}
const profile = getProfile(email) ?? ensureProfile(email);
const nextFilename = `${randomToken(18)}.${imageKind.ext}`;
const absolutePath = join(config.avatarDir, nextFilename);
await Bun.write(absolutePath, bytes);
await deleteAvatarFile(profile.avatar_path);
const updated = setAvatarPath(email, absolutePath);
return {
email: updated.email,
displayName: updated.display_name,
role: updated.role,
avatarUrl: toAvatarUrl(updated.email, updated.avatar_path),
totpEnabled: Boolean(updated.totp_enabled)
};
};
export const getAvatarFile = (email: string): Bun.BunFile => {
const profile = getProfile(email);
if (!profile?.avatar_path) {
throw new HttpError(404, "Avatar not found.");
}
return Bun.file(profile.avatar_path);
};
export const getForwardingSettings = (email: string): ForwardingRule => {
const current = getForwardingRule(email);
return {
enabled: Boolean(current?.enabled),
target: current?.target_email ?? null,
allowedDomains: config.forwardingAllowedDomains
};
};
export const updateForwarding = async (email: string, enabled: boolean, target: string | null): Promise<ForwardingRule> => {
ensureForwardingTargetAllowed(email, target);
const previous = getForwardingRule(email);
if (previous?.enabled && previous.target_email) {
await removeForwardingAlias(email, previous.target_email);
}
if (enabled && target) {
await addForwardingAlias(email, target);
}
const next = upsertForwardingRule(email, enabled ? target : null, enabled);
return {
enabled: Boolean(next.enabled),
target: next.target_email ?? null,
allowedDomains: config.forwardingAllowedDomains
};
};
+29
View File
@@ -0,0 +1,29 @@
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;
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM"
]
},
"include": [
"src"
]
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>Secure Webmail</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@internal-mail/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@internal-mail/shared": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"bun-types": "^1.3.11",
"typescript": "^5.8.3",
"vite": "^6.2.4"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import type { ProfileSettings } from "@internal-mail/shared";
import { LoginPage } from "./pages/LoginPage";
import { MailboxPage } from "./pages/MailboxPage";
import { SettingsPage } from "./pages/SettingsPage";
import { useSession } from "./hooks/useSession";
type View = "inbox" | "compose" | "settings";
const AppShell = () => {
const { session, logout } = useSession();
const [currentView, setCurrentView] = useState<View>("inbox");
const [profileOverride, setProfileOverride] = useState<ProfileSettings | null>(null);
const user = profileOverride ?? session.user;
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<div className="brand-block__overline">Zero-trust posture</div>
<h1>Secure Webmail</h1>
<p>Docker-managed mailboxes with a web layer designed to fail closed.</p>
</div>
<nav className="nav-stack">
<button type="button" className={currentView === "inbox" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("inbox")}>
Inbox
</button>
<button type="button" className={currentView === "compose" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("compose")}>
Compose
</button>
<button type="button" className={currentView === "settings" ? "nav-button nav-button--active" : "nav-button"} onClick={() => setCurrentView("settings")}>
Settings
</button>
</nav>
<div className="sidebar__footer">
<div className="identity-card">
{user?.avatarUrl ? <img src={user.avatarUrl} alt="" className="avatar-mini" /> : <div className="avatar-fallback">{user?.displayName.slice(0, 1) ?? "?"}</div>}
<div>
<strong>{user?.displayName}</strong>
<p>{user?.email}</p>
<small>{user?.totpEnabled ? "2FA enabled" : "2FA optional"}</small>
</div>
</div>
<button type="button" className="ghost-button" onClick={() => void logout()}>
Sign out
</button>
</div>
</aside>
<section className="content-shell">
{currentView === "settings" ? (
<SettingsPage onProfileUpdate={(profile) => setProfileOverride(profile)} />
) : (
<MailboxPage currentView={currentView === "compose" ? "compose" : "inbox"} />
)}
</section>
</main>
);
};
export const App = () => {
const { loading, authBusy, error, login, session } = useSession();
if (loading) {
return <main className="loading-screen">Loading secure session context...</main>;
}
if (!session.authenticated) {
return <LoginPage busy={authBusy} error={error} onLogin={login} />;
}
return <AppShell />;
};
+157
View File
@@ -0,0 +1,157 @@
import type {
ForwardingRule,
LoginResponse,
MailMessageDetail,
MailMessageSummary,
ProfileSettings,
SessionView,
TotpSetupResponse
} from "@internal-mail/shared";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
let csrfToken: string | null = null;
type RequestOptions = RequestInit & {
includeCsrf?: boolean;
};
const parseResponse = async <T>(response: Response): Promise<T> => {
if (response.status === 204) {
return undefined as T;
}
const contentType = response.headers.get("content-type") ?? "";
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok) {
const message = payload && typeof payload.error === "string" ? payload.error : "Request failed.";
throw new Error(message);
}
return payload as T;
};
const request = async <T>(path: string, options?: RequestOptions): Promise<T> => {
const headers = new Headers(options?.headers);
const isFormData = options?.body instanceof FormData;
if (!isFormData && options?.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (options?.includeCsrf && csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
credentials: "include",
headers
});
return parseResponse<T>(response);
};
export const apiClient = {
setCsrfToken(value: string | null) {
csrfToken = value;
},
async getSession(): Promise<SessionView> {
const payload = await request<{ session: SessionView }>("/api/auth/session");
csrfToken = payload.session.csrfToken;
return payload.session;
},
async login(email: string, password: string, totpCode?: string): Promise<SessionView> {
const payload = await request<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password, totpCode })
});
csrfToken = payload.session.csrfToken;
return payload.session;
},
async logout(): Promise<void> {
await request("/api/auth/logout", {
method: "POST",
includeCsrf: true
});
csrfToken = null;
},
async getMessages(): Promise<MailMessageSummary[]> {
const payload = await request<{ items: MailMessageSummary[] }>("/api/mail/messages");
return payload.items;
},
async getMessage(id: string): Promise<MailMessageDetail> {
return request<MailMessageDetail>(`/api/mail/messages/${encodeURIComponent(id)}`);
},
async sendMessage(to: string[], subject: string, textBody: string): Promise<void> {
await request("/api/mail/send", {
method: "POST",
includeCsrf: true,
body: JSON.stringify({ to, subject, textBody })
});
},
async getProfile(): Promise<ProfileSettings> {
return request<ProfileSettings>("/api/settings/profile");
},
async updateProfile(displayName: string): Promise<ProfileSettings> {
return request<ProfileSettings>("/api/settings/profile", {
method: "PATCH",
includeCsrf: true,
body: JSON.stringify({ displayName })
});
},
async uploadAvatar(file: File): Promise<ProfileSettings> {
const formData = new FormData();
formData.set("avatar", file);
return request<ProfileSettings>("/api/settings/avatar", {
method: "POST",
includeCsrf: true,
body: formData
});
},
async getForwarding(): Promise<ForwardingRule> {
return request<ForwardingRule>("/api/settings/forwarding");
},
async updateForwarding(enabled: boolean, target: string | null): Promise<ForwardingRule> {
return request<ForwardingRule>("/api/settings/forwarding", {
method: "PUT",
includeCsrf: true,
body: JSON.stringify({ enabled, target })
});
},
async setupTotp(): Promise<TotpSetupResponse> {
return request<TotpSetupResponse>("/api/auth/totp/setup", {
method: "POST",
includeCsrf: true
});
},
async enableTotp(code: string): Promise<void> {
await request("/api/auth/totp/enable", {
method: "POST",
includeCsrf: true,
body: JSON.stringify({ code })
});
},
async disableTotp(code: string): Promise<void> {
await request("/api/auth/totp/disable", {
method: "POST",
includeCsrf: true,
body: JSON.stringify({ code })
});
}
};
+29
View File
@@ -0,0 +1,29 @@
import type { MailMessageSummary } from "@internal-mail/shared";
type MessageListProps = {
items: MailMessageSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
};
export const MessageList = ({ items, selectedId, onSelect }: MessageListProps) => (
<div className="message-list" aria-label="Mailbox messages">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`message-item${selectedId === item.id ? " message-item--active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="message-item__from">{item.from}</span>
<span className="message-item__subject">{item.subject}</span>
<span className="message-item__preview">{item.preview || "No preview available"}</span>
<span className="message-item__meta">
<time dateTime={item.receivedAt}>{new Date(item.receivedAt).toLocaleString()}</time>
{item.hasHtml ? <strong>HTML</strong> : <span>Text</span>}
</span>
</button>
))}
</div>
);
+21
View File
@@ -0,0 +1,21 @@
type SafeMailFrameProps = {
html: string | null;
text: string;
};
export const SafeMailFrame = ({ html, text }: SafeMailFrameProps) => {
if (!html) {
return <pre className="mail-text">{text || "No message body."}</pre>;
}
return (
<iframe
className="mail-frame"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={html}
title="Sanitized HTML email preview"
/>
);
};
+84
View File
@@ -0,0 +1,84 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import type { SessionView } from "@internal-mail/shared";
import { apiClient } from "../api/client";
type SessionContextValue = {
session: SessionView;
loading: boolean;
authBusy: boolean;
error: string | null;
login: (email: string, password: string, totpCode?: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
};
const anonymousSession: SessionView = {
authenticated: false,
csrfToken: null,
user: null
};
const SessionContext = createContext<SessionContextValue | null>(null);
export const SessionProvider = ({ children }: PropsWithChildren) => {
const [session, setSession] = useState<SessionView>(anonymousSession);
const [loading, setLoading] = useState(true);
const [authBusy, setAuthBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
try {
const nextSession = await apiClient.getSession();
setSession(nextSession);
setError(null);
} catch (requestError) {
setSession(anonymousSession);
setError(requestError instanceof Error ? requestError.message : "Unable to load session.");
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
}, []);
const value: SessionContextValue = {
session,
loading,
authBusy,
error,
login: async (email, password, totpCode) => {
try {
setAuthBusy(true);
const nextSession = await apiClient.login(email, password, totpCode);
setSession(nextSession);
setError(null);
} finally {
setAuthBusy(false);
}
},
logout: async () => {
try {
setAuthBusy(true);
await apiClient.logout();
setSession(anonymousSession);
} finally {
setAuthBusy(false);
}
},
refresh
};
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
};
export const useSession = (): SessionContextValue => {
const value = useContext(SessionContext);
if (!value) {
throw new Error("useSession must be used inside SessionProvider.");
}
return value;
};
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { SessionProvider } from "./hooks/useSession";
import "./styles/app.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found.");
}
createRoot(root).render(
<StrictMode>
<SessionProvider>
<App />
</SessionProvider>
</StrictMode>
);
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
import type { FormEvent } from "react";
type LoginPageProps = {
busy: boolean;
error: string | null;
onLogin: (email: string, password: string, totpCode?: string) => Promise<void>;
};
export const LoginPage = ({ busy, error, onLogin }: LoginPageProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onLogin(email, password, totpCode || undefined);
};
return (
<main className="auth-shell">
<section className="auth-card">
<div className="auth-card__eyebrow">Public internet hardened</div>
<h1>Secure Webmail</h1>
<p>
Sessions stay on the server, cookies are <code>httpOnly</code>, and every state-changing request carries an in-memory CSRF token.
</p>
<form className="auth-form" onSubmit={handleSubmit}>
<label>
Email
<input
autoComplete="username"
inputMode="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<label>
Two-factor code
<input
autoComplete="one-time-code"
inputMode="numeric"
pattern="\d{6}"
type="text"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
placeholder="123456"
/>
</label>
{error ? <div className="form-error">{error}</div> : null}
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Signing in..." : "Sign in"}
</button>
</form>
</section>
</main>
);
};
+194
View File
@@ -0,0 +1,194 @@
import { startTransition, useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { MailMessageDetail, MailMessageSummary } from "@internal-mail/shared";
import { apiClient } from "../api/client";
import { MessageList } from "../components/MessageList";
import { SafeMailFrame } from "../components/SafeMailFrame";
type MailboxPageProps = {
currentView: "inbox" | "compose";
};
export const MailboxPage = ({ currentView }: MailboxPageProps) => {
const [items, setItems] = useState<MailMessageSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [message, setMessage] = useState<MailMessageDetail | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [composeError, setComposeError] = useState<string | null>(null);
const [composeSuccess, setComposeSuccess] = useState<string | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [to, setTo] = useState("");
const [subject, setSubject] = useState("");
const [textBody, setTextBody] = useState("");
const refreshMailbox = async () => {
try {
const nextItems = await apiClient.getMessages();
startTransition(() => {
setItems(nextItems);
setSelectedId((current) => current ?? nextItems[0]?.id ?? null);
});
setListError(null);
} catch (error) {
setListError(error instanceof Error ? error.message : "Unable to load mailbox.");
}
};
useEffect(() => {
void refreshMailbox();
}, []);
useEffect(() => {
if (!selectedId) {
setMessage(null);
return;
}
void (async () => {
try {
setDetailLoading(true);
const nextMessage = await apiClient.getMessage(selectedId);
setMessage(nextMessage);
setDetailError(null);
} catch (error) {
setDetailError(error instanceof Error ? error.message : "Unable to load message.");
} finally {
setDetailLoading(false);
}
})();
}, [selectedId]);
const currentMessage = items.find((item) => item.id === selectedId) ?? null;
const handleCompose = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.sendMessage(
to
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
subject,
textBody
);
setComposeSuccess("Message queued through docker-mailserver.");
setComposeError(null);
setTo("");
setSubject("");
setTextBody("");
} catch (error) {
setComposeError(error instanceof Error ? error.message : "Unable to send message.");
setComposeSuccess(null);
}
};
if (currentView === "compose") {
return (
<section className="panel">
<div className="panel__header">
<h2>Compose</h2>
<p>Outgoing mail is sent as plain text by default to reduce rendering abuse and hidden content risks.</p>
</div>
<form className="stack-form" onSubmit={handleCompose}>
<label>
To
<input
type="text"
value={to}
onChange={(event) => setTo(event.target.value)}
placeholder="[email protected], [email protected]"
required
/>
</label>
<label>
Subject
<input type="text" value={subject} onChange={(event) => setSubject(event.target.value)} maxLength={160} />
</label>
<label>
Message
<textarea rows={16} value={textBody} onChange={(event) => setTextBody(event.target.value)} required />
</label>
{composeError ? <div className="form-error">{composeError}</div> : null}
{composeSuccess ? <div className="form-success">{composeSuccess}</div> : null}
<button type="submit" className="primary-button">
Send
</button>
</form>
</section>
);
}
return (
<section className="mail-grid">
<aside className="panel">
<div className="panel__header">
<h2>Inbox</h2>
<button type="button" className="ghost-button" onClick={() => void refreshMailbox()}>
Refresh
</button>
</div>
{listError ? <div className="form-error">{listError}</div> : null}
<MessageList
items={items}
selectedId={selectedId}
onSelect={(id) =>
startTransition(() => {
setSelectedId(id);
})
}
/>
</aside>
<article className="panel mail-panel">
<div className="panel__header">
<div>
<h2>{currentMessage?.subject ?? "Message detail"}</h2>
<p>{currentMessage ? `Selected message from ${currentMessage.from}` : "Choose a message to inspect."}</p>
</div>
{detailLoading ? <span className="status-chip">Loading...</span> : null}
</div>
{detailError ? <div className="form-error">{detailError}</div> : null}
{message ? (
<div className="mail-detail">
<dl className="mail-detail__meta">
<div>
<dt>From</dt>
<dd>{message.from}</dd>
</div>
<div>
<dt>To</dt>
<dd>{message.to.join(", ") || "Unknown"}</dd>
</div>
<div>
<dt>Received</dt>
<dd>{new Date(message.receivedAt).toLocaleString()}</dd>
</div>
</dl>
{message.attachments.length ? (
<div className="attachment-bar">
{message.attachments.map((attachment) => (
<span key={`${attachment.filename}-${attachment.size ?? "na"}`} className="status-chip">
{attachment.filename}
</span>
))}
</div>
) : null}
<SafeMailFrame html={message.safeHtml} text={message.textBody} />
</div>
) : (
<div className="empty-state">Select a message to view a sanitized preview.</div>
)}
</article>
</section>
);
};
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { ForwardingRule, ProfileSettings, TotpSetupResponse } from "@internal-mail/shared";
import { apiClient } from "../api/client";
type SettingsPageProps = {
onProfileUpdate: (profile: ProfileSettings) => void;
};
export const SettingsPage = ({ onProfileUpdate }: SettingsPageProps) => {
const [profile, setProfile] = useState<ProfileSettings | null>(null);
const [forwarding, setForwarding] = useState<ForwardingRule | null>(null);
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
const [displayName, setDisplayName] = useState("");
const [forwardTarget, setForwardTarget] = useState("");
const [forwardEnabled, setForwardEnabled] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const [nextProfile, nextForwarding] = await Promise.all([apiClient.getProfile(), apiClient.getForwarding()]);
setProfile(nextProfile);
setDisplayName(nextProfile.displayName);
setForwarding(nextForwarding);
setForwardTarget(nextForwarding.target ?? "");
setForwardEnabled(nextForwarding.enabled);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Unable to load settings.");
}
};
useEffect(() => {
void load();
}, []);
const handleProfileSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const nextProfile = await apiClient.updateProfile(displayName);
setProfile(nextProfile);
onProfileUpdate(nextProfile);
setStatus("Display name updated.");
setError(null);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to update profile.");
}
};
const handleAvatarChange = async (event: FormEvent<HTMLInputElement>) => {
const target = event.currentTarget.files?.[0];
if (!target) {
return;
}
try {
const nextProfile = await apiClient.uploadAvatar(target);
setProfile(nextProfile);
onProfileUpdate(nextProfile);
setStatus("Avatar updated.");
setError(null);
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "Unable to update avatar.");
}
};
const handleForwardingSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const nextForwarding = await apiClient.updateForwarding(forwardEnabled, forwardEnabled ? forwardTarget : null);
setForwarding(nextForwarding);
setStatus("Forwarding settings saved.");
setError(null);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to save forwarding.");
}
};
const handleTotpSetup = async () => {
try {
const setup = await apiClient.setupTotp();
setTotpSetup(setup);
setStatus("Scan the secret with your authenticator app, then verify below.");
setError(null);
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Unable to prepare two-factor authentication.");
}
};
const handleTotpEnable = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.enableTotp(totpCode);
setTotpSetup(null);
setTotpCode("");
await load();
setStatus("Two-factor authentication enabled.");
setError(null);
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Unable to enable two-factor authentication.");
}
};
const handleTotpDisable = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await apiClient.disableTotp(totpCode);
setTotpCode("");
await load();
setStatus("Two-factor authentication disabled.");
setError(null);
} catch (disableError) {
setError(disableError instanceof Error ? disableError.message : "Unable to disable two-factor authentication.");
}
};
return (
<section className="settings-grid">
<article className="panel">
<div className="panel__header">
<h2>Profile</h2>
<p>Avatar files are type-checked, size-limited, renamed safely, and served from a controlled endpoint.</p>
</div>
<form className="stack-form" onSubmit={handleProfileSubmit}>
<label>
Display name
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} maxLength={80} required />
</label>
<label>
Avatar
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleAvatarChange} />
</label>
{profile?.avatarUrl ? <img className="avatar-preview" src={profile.avatarUrl} alt="" /> : null}
<button type="submit" className="primary-button">
Save profile
</button>
</form>
</article>
<article className="panel">
<div className="panel__header">
<h2>Forwarding</h2>
<p>Forwarding is restricted by policy so users cannot create loops or spray mail to arbitrary domains.</p>
</div>
<form className="stack-form" onSubmit={handleForwardingSubmit}>
<label className="toggle-row">
<input type="checkbox" checked={forwardEnabled} onChange={(event) => setForwardEnabled(event.target.checked)} />
Enable forwarding
</label>
<label>
Target mailbox
<input
type="email"
value={forwardTarget}
onChange={(event) => setForwardTarget(event.target.value)}
disabled={!forwardEnabled}
placeholder={forwarding?.allowedDomains[0] ? `user@${forwarding.allowedDomains[0]}` : "[email protected]"}
/>
</label>
<div className="helper-text">
Allowed domains: {forwarding?.allowedDomains.join(", ") || "Loading policy..."}
</div>
<button type="submit" className="primary-button">
Save forwarding
</button>
</form>
</article>
<article className="panel">
<div className="panel__header">
<h2>Two-factor authentication</h2>
<p>TOTP secrets are encrypted at rest and never sent back after setup.</p>
</div>
<div className="totp-panel">
{!profile?.totpEnabled ? (
<>
<button type="button" className="ghost-button" onClick={() => void handleTotpSetup()}>
Generate secret
</button>
{totpSetup ? (
<div className="totp-secret">
<code>{totpSetup.secret}</code>
<p className="helper-text">URI: {totpSetup.otpauthUrl}</p>
</div>
) : null}
<form className="stack-form" onSubmit={handleTotpEnable}>
<label>
Verify code
<input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
pattern="\d{6}"
inputMode="numeric"
placeholder="123456"
/>
</label>
<button type="submit" className="primary-button" disabled={!totpSetup}>
Enable 2FA
</button>
</form>
</>
) : (
<form className="stack-form" onSubmit={handleTotpDisable}>
<label>
Confirm code to disable
<input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
pattern="\d{6}"
inputMode="numeric"
placeholder="123456"
/>
</label>
<button type="submit" className="ghost-button">
Disable 2FA
</button>
</form>
)}
</div>
</article>
{status ? <div className="form-success settings-grid__status">{status}</div> : null}
{error ? <div className="form-error settings-grid__status">{error}</div> : null}
</section>
);
};
+389
View File
@@ -0,0 +1,389 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
color: #f5f5f0;
background:
radial-gradient(circle at top left, rgba(255, 196, 96, 0.25), transparent 30%),
radial-gradient(circle at bottom right, rgba(40, 122, 95, 0.4), transparent 25%),
linear-gradient(135deg, #16211d 0%, #0d1514 50%, #071214 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
code {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.loading-screen,
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.auth-card {
width: min(100%, 28rem);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(9, 20, 18, 0.82);
backdrop-filter: blur(18px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
.auth-card__eyebrow,
.brand-block__overline {
display: inline-flex;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(255, 196, 96, 0.18);
color: #ffd479;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.auth-card h1,
.brand-block h1 {
margin: 1rem 0 0.4rem;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.95;
}
.auth-form,
.stack-form,
.totp-panel {
display: grid;
gap: 1rem;
}
label {
display: grid;
gap: 0.45rem;
font-size: 0.95rem;
}
input,
textarea {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: 0.95rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
color: inherit;
}
textarea {
resize: vertical;
}
.primary-button,
.ghost-button,
.nav-button {
border: 0;
border-radius: 999px;
padding: 0.85rem 1.1rem;
transition: transform 160ms ease, background 160ms ease, color 160ms ease;
}
.primary-button {
background: linear-gradient(135deg, #f0c05f 0%, #db9353 100%);
color: #16120b;
font-weight: 700;
}
.ghost-button,
.nav-button {
background: rgba(255, 255, 255, 0.06);
color: inherit;
}
.primary-button:hover,
.ghost-button:hover,
.nav-button:hover {
transform: translateY(-1px);
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 20rem minmax(0, 1fr);
}
.sidebar {
padding: 1.5rem;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(3, 10, 11, 0.56);
display: grid;
grid-template-rows: auto 1fr auto;
gap: 1.5rem;
}
.nav-stack {
display: grid;
align-content: start;
gap: 0.75rem;
}
.nav-button {
text-align: left;
}
.nav-button--active {
background: rgba(255, 196, 96, 0.2);
color: #ffd479;
}
.sidebar__footer {
display: grid;
gap: 1rem;
}
.identity-card {
display: flex;
gap: 0.9rem;
align-items: center;
padding: 0.9rem;
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.04);
}
.avatar-mini,
.avatar-fallback,
.avatar-preview {
width: 3rem;
height: 3rem;
border-radius: 1rem;
object-fit: cover;
}
.avatar-fallback {
display: grid;
place-items: center;
background: rgba(255, 196, 96, 0.14);
color: #ffd479;
font-weight: 700;
}
.content-shell {
padding: 1.5rem;
}
.mail-grid,
.settings-grid {
display: grid;
gap: 1.25rem;
}
.mail-grid {
grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr);
}
.settings-grid {
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.settings-grid__status {
grid-column: 1 / -1;
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.5rem;
background: rgba(9, 20, 18, 0.78);
padding: 1.2rem;
min-height: 12rem;
}
.panel__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.panel__header h2 {
margin: 0;
}
.message-list {
display: grid;
gap: 0.75rem;
max-height: calc(100vh - 13rem);
overflow: auto;
}
.message-item {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.1rem;
padding: 0.95rem;
text-align: left;
display: grid;
gap: 0.3rem;
background: rgba(255, 255, 255, 0.03);
color: inherit;
}
.message-item--active {
border-color: rgba(255, 196, 96, 0.55);
background: rgba(255, 196, 96, 0.12);
}
.message-item__from {
font-weight: 700;
}
.message-item__subject {
font-size: 1rem;
}
.message-item__preview,
.helper-text,
.identity-card p,
.identity-card small,
.panel__header p {
color: rgba(245, 245, 240, 0.72);
}
.message-item__meta {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: center;
font-size: 0.85rem;
}
.mail-panel {
min-height: calc(100vh - 3rem);
}
.mail-detail {
display: grid;
gap: 1rem;
}
.mail-detail__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.9rem;
}
.mail-detail__meta dt {
font-weight: 700;
}
.mail-detail__meta dd {
margin: 0.25rem 0 0;
color: rgba(245, 245, 240, 0.75);
}
.mail-frame {
width: 100%;
min-height: 32rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: white;
}
.mail-text {
white-space: pre-wrap;
margin: 0;
padding: 1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.attachment-bar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.7rem;
}
.toggle-row input {
width: auto;
}
.totp-secret {
padding: 0.9rem;
border-radius: 1rem;
background: rgba(255, 196, 96, 0.08);
}
.form-error,
.form-success,
.empty-state {
padding: 0.9rem 1rem;
border-radius: 1rem;
}
.form-error {
background: rgba(223, 74, 74, 0.16);
color: #ffb7b7;
}
.form-success {
background: rgba(82, 176, 107, 0.18);
color: #c7f4cf;
}
.empty-state {
background: rgba(255, 255, 255, 0.03);
}
@media (max-width: 980px) {
.app-shell,
.mail-grid {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.mail-panel {
min-height: auto;
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
]
},
"include": [
"src",
"vite.config.ts"
]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true
}
}
}
});
+376
View File
@@ -0,0 +1,376 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "internal-mail-server",
},
"apps/api": {
"name": "@internal-mail/api",
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0",
"html-to-text": "^9.0.5",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2",
},
"devDependencies": {
"bun-types": "^1.3.11",
"typescript": "^5.8.3",
},
},
"apps/web": {
"name": "@internal-mail/web",
"version": "0.0.1",
"dependencies": {
"@internal-mail/shared": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0",
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"bun-types": "^1.3.11",
"typescript": "^5.8.3",
"vite": "^6.2.4",
},
},
"packages/shared": {
"name": "@internal-mail/shared",
"version": "0.0.1",
"devDependencies": {
"bun-types": "^1.3.11",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@internal-mail/api": ["@internal-mail/api@workspace:apps/api"],
"@internal-mail/shared": ["@internal-mail/shared@workspace:packages/shared"],
"@internal-mail/web": ["@internal-mail/web@workspace:apps/web"],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@noble/hashes": ["@noble/[email protected]", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@selderee/plugin-htmlparser2": ["@selderee/[email protected]", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
"@tokenizer/inflate": ["@tokenizer/[email protected]", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/[email protected]", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.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=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="],
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-type": ["[email protected]", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"html-to-text": ["[email protected]", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leac": ["[email protected]", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["[email protected]", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"otpauth": ["[email protected]", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA=="],
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
"parseley": ["[email protected]", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"peberminta": ["[email protected]", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postal-mime": ["[email protected]", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
"sanitize-html": ["[email protected]", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"selderee": ["[email protected]", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strtok3": ["[email protected]", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"token-types": ["[email protected]", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["[email protected]", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.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=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sanitize-html/htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"sanitize-html/htmlparser2/entities": ["[email protected]", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
}
}
+1
View File
@@ -0,0 +1 @@
+41
View File
@@ -0,0 +1,41 @@
server {
listen 443 ssl http2;
server_name mail.example.com;
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
client_max_body_size 3m;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self';" always;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
location /api/ {
proxy_pass http://api:3001/api/;
}
location / {
proxy_pass http://web:5173/;
}
}
server {
listen 80;
server_name mail.example.com;
return 301 https://$host$request_uri;
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "internal-mail-server",
"private": true,
"packageManager": "[email protected]",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev:api": "bun run --cwd apps/api dev",
"dev:web": "bun run --cwd apps/web dev",
"build": "bun run --cwd packages/shared build && bun run --cwd apps/api build && bun run --cwd apps/web build",
"typecheck": "bun run --cwd packages/shared typecheck && bun run --cwd apps/api typecheck && bun run --cwd apps/web typecheck"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@internal-mail/shared",
"version": "0.0.1",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"bun-types": "^1.3.11",
"typescript": "^5.8.3"
}
}
+60
View File
@@ -0,0 +1,60 @@
export type UserRole = "admin" | "user";
export interface SessionUser {
email: string;
displayName: string;
role: UserRole;
avatarUrl: string | null;
totpEnabled: boolean;
}
export interface SessionView {
authenticated: boolean;
csrfToken: string | null;
user: SessionUser | null;
}
export interface ForwardingRule {
enabled: boolean;
target: string | null;
allowedDomains: string[];
}
export interface ProfileSettings {
email: string;
displayName: string;
role: UserRole;
avatarUrl: string | null;
totpEnabled: boolean;
}
export interface MailAttachment {
filename: string;
mimeType: string;
size: number | null;
}
export interface MailMessageSummary {
id: string;
from: string;
subject: string;
preview: string;
receivedAt: string;
hasHtml: boolean;
}
export interface MailMessageDetail extends MailMessageSummary {
to: string[];
textBody: string;
safeHtml: string | null;
attachments: MailAttachment[];
}
export interface LoginResponse {
session: SessionView;
}
export interface TotpSetupResponse {
secret: string;
otpauthUrl: string;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"emitDeclarationOnly": true
},
"include": [
"src"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"types": [
"bun-types"
],
"baseUrl": ".",
"paths": {
"@internal-mail/shared": [
"packages/shared/src/index.ts"
]
}
}
}