caca
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
});
|
||||
};
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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 })
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user