re-vibecode

This commit is contained in:
Debian
2026-03-30 13:57:39 +00:00
parent 09d76c044b
commit bb8497d33d
29 changed files with 1040 additions and 1622 deletions
+3 -7
View File
@@ -9,15 +9,11 @@
},
"dependencies": {
"@internal-mail/shared": "workspace:*",
"elysia": "^1.3.0",
"html-to-text": "^9.0.5",
"otpauth": "^9.4.1",
"postal-mime": "^2.4.4",
"sanitize-html": "^2.17.0",
"zod": "^3.24.2"
"elysia": "^1.3.10",
"zod": "^3.25.0"
},
"devDependencies": {
"bun-types": "^1.3.11",
"bun-types": "^1.3.10",
"typescript": "^5.8.3"
}
}
+63 -137
View File
@@ -1,174 +1,100 @@
import * as OTPAuth from "otpauth";
import type { SessionView, UserRole } from "@internal-mail/shared";
import { authenticateMailbox } from "../docker/mailserver";
import type { AdminSession } from "@internal-mail/shared";
import { config } from "../lib/config";
import { decryptAtRest, encryptAtRest } from "../lib/crypto";
import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db";
import { createSession, deleteSession, getAdmin, getSession, upsertAdmin } from "../lib/db";
import { HttpError } from "../lib/errors";
import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation";
import {
buildClearedCookieValue,
buildCookieValue,
createOpaqueToken,
hashToken,
readCookie
} from "../lib/security";
const issuer = "Secure Webmail";
const cookieMaxAgeSeconds = Math.floor(config.sessionTtlMs / 1000);
const createTotp = (email: string, secret: string): OTPAuth.TOTP =>
new OTPAuth.TOTP({
issuer,
label: email,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret)
});
const buildSessionView = (email: string, role: UserRole, csrfToken: string | null): SessionView => {
const profile = ensureProfile(email, role);
return {
authenticated: true,
csrfToken,
user: {
email: profile.email,
displayName: profile.display_name,
role,
avatarUrl: profile.avatar_path ? `/api/settings/avatar/${encodeURIComponent(profile.email)}` : null,
totpEnabled: Boolean(profile.totp_enabled)
}
};
};
const sessionView = (adminEmail: string | null): AdminSession => ({
authenticated: Boolean(adminEmail),
adminEmail
});
export const seedAdminAccount = async (): Promise<void> => {
if (!config.adminEmail || !config.adminPassword) {
if (!config.appAdminEmail || !config.appAdminPassword) {
return;
}
const existing = getAdmin(config.adminEmail);
if (existing && (await Bun.password.verify(config.adminPassword, existing.password_hash))) {
ensureProfile(config.adminEmail, "admin");
const existing = getAdmin(config.appAdminEmail);
if (existing && (await Bun.password.verify(config.appAdminPassword, existing.password_hash))) {
return;
}
const passwordHash = await Bun.password.hash(config.adminPassword, {
const passwordHash = await Bun.password.hash(config.appAdminPassword, {
algorithm: "argon2id",
memoryCost: 19456,
memoryCost: 19_456,
timeCost: 2
});
upsertAdmin(config.adminEmail, passwordHash);
upsertAdmin(config.appAdminEmail, passwordHash);
};
export const login = async (
email: string,
password: string,
totpCode: string | undefined,
_request: Request
): Promise<{ session: SessionView }> => {
const normalizedEmail = strictEmailSchema.parse(email);
let role: UserRole = "user";
if (config.allowInsecureLogin) {
if (config.adminEmail && normalizedEmail === config.adminEmail) {
role = "admin";
}
return {
session: buildSessionView(normalizedEmail, role, null)
};
export const loginAdmin = async (email: string, password: string): Promise<{
session: AdminSession;
cookie: string;
}> => {
const admin = getAdmin(email);
if (!admin) {
throw new HttpError(401, "Invalid email or password.");
}
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 validPassword = await Bun.password.verify(password, admin.password_hash);
if (!validPassword) {
throw new HttpError(401, "Invalid email or password.");
}
const profile = ensureProfile(normalizedEmail, role);
if (profile.totp_enabled) {
if (!totpCode) {
throw new HttpError(401, "Two-factor code is required.");
}
const rawToken = createOpaqueToken();
const tokenHash = hashToken(rawToken);
const expiresAt = Date.now() + config.sessionTtlMs;
const decryptedSecret = await decryptAtRest(profile.totp_secret!);
const delta = createTotp(normalizedEmail, decryptedSecret).validate({ token: totpCode, window: 1 });
if (delta === null) {
throw new HttpError(401, "Invalid two-factor code.");
}
}
createSession(tokenHash, admin.email, expiresAt);
return {
session: buildSessionView(normalizedEmail, role, null)
session: sessionView(admin.email),
cookie: buildCookieValue(config.sessionCookieName, rawToken, {
maxAgeSeconds: cookieMaxAgeSeconds,
secure: config.isProduction
})
};
};
export const getSessionFromRequest = async (request: Request) => {
const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail;
if (!headerEmail) {
throw new HttpError(401, "X-User-Email header is required.");
export const logoutAdmin = (request: Request): string => {
const rawToken = readCookie(request, config.sessionCookieName);
if (rawToken) {
deleteSession(hashToken(rawToken));
}
const email = strictEmailSchema.parse(headerEmail);
const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user";
ensureProfile(email, role);
return buildClearedCookieValue(config.sessionCookieName, config.isProduction);
};
export const requireAdminSession = (request: Request): { adminEmail: string } => {
const rawToken = readCookie(request, config.sessionCookieName);
if (!rawToken) {
throw new HttpError(401, "Authentication required.");
}
const session = getSession(hashToken(rawToken));
if (!session || session.expires_at <= Date.now()) {
throw new HttpError(401, "Authentication required.");
}
return {
id: "stateless",
email,
role,
csrfToken: null,
session: buildSessionView(email, role, null)
adminEmail: session.admin_email
};
};
export const getAnonymousSession = (): SessionView => ({
authenticated: false,
csrfToken: null,
user: null
});
export const createTotpSecret = async (email: string): Promise<{ secret: string; otpauthUrl: string }> => {
const profile = getProfile(email) ?? ensureProfile(email);
const secret = new OTPAuth.Secret({ size: 20 }).base32;
const encrypted = await encryptAtRest(secret);
setTotpSecret(profile.email, encrypted, false);
return {
secret,
otpauthUrl: createTotp(email, secret).toString()
};
export const getSessionFromRequest = (request: Request): AdminSession => {
try {
return sessionView(requireAdminSession(request).adminEmail);
} catch {
return sessionView(null);
}
};
export const enableTotp = async (email: string, code: string): Promise<void> => {
const profile = getProfile(email) ?? ensureProfile(email);
if (!profile.totp_secret) {
throw new HttpError(400, "Set up two-factor authentication first.");
}
const secret = await decryptAtRest(profile.totp_secret);
const delta = createTotp(email, secret).validate({ token: code, window: 1 });
if (delta === null) {
throw new HttpError(400, "Invalid two-factor code.");
}
setTotpSecret(email, profile.totp_secret, true);
};
export const disableTotp = async (email: string, code: string): Promise<void> => {
const profile = getProfile(email) ?? ensureProfile(email);
if (!profile.totp_enabled || !profile.totp_secret) {
throw new HttpError(400, "Two-factor authentication is not enabled.");
}
const secret = await decryptAtRest(profile.totp_secret);
const delta = createTotp(email, secret).validate({ token: code, window: 1 });
if (delta === null) {
throw new HttpError(400, "Invalid two-factor code.");
}
setTotpSecret(email, null, false);
};
+27 -88
View File
@@ -1,104 +1,43 @@
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
import { hostedMailboxSchema } from "../lib/validation";
import type { MailUser } from "@internal-mail/shared";
import { runDocker } from "./runner";
const MAILDIR_FOLDER = "INBOX";
const infrastructureErrorPatterns = [
/cannot connect to the docker daemon/i,
/permission denied/i,
/no such container/i,
/is not running/i,
/executable file not found/i,
/no such file or directory/i,
/command not found/i
];
const emailPattern = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
const looksLikeInfrastructureFailure = (output: string): boolean =>
infrastructureErrorPatterns.some((pattern) => pattern.test(output));
const toMailUser = (email: string): MailUser => {
const [rawLocalPart, rawDomain] = email.split("@");
const localPart = rawLocalPart ?? email;
const domain = rawDomain ?? "";
const localPartRegex = /^[A-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}$/i;
return {
email,
localPart,
domain
};
};
const splitHostedEmail = (email: string): { localPart: string; domain: string } => {
const normalized = hostedMailboxSchema.parse(email);
const [localPart, domain] = normalized.split("@");
if (!localPart || !domain || !localPartRegex.test(localPart)) {
throw new HttpError(400, "Unsupported mailbox format.");
export const listMailUsers = async (): Promise<MailUser[]> => {
const { stdout } = await runDocker(["setup", "email", "list"]);
const emails = new Set<string>();
for (const match of stdout.matchAll(emailPattern)) {
emails.add(match[0].toLowerCase());
}
return { localPart, domain };
return [...emails].sort().map(toMailUser);
};
const buildMaildirRoot = (email: string): string => {
const { localPart, domain } = splitHostedEmail(email);
return `/var/mail/${domain}/${localPart}/Maildir`;
export const addMailUser = async (email: string, password: string): Promise<void> => {
await runDocker(["setup", "email", "add", email, password]);
};
export const authenticateMailbox = async (email: string, password: string): Promise<boolean> => {
const normalizedEmail = hostedMailboxSchema.parse(email);
const result = await runDocker(
["exec", config.mailserverContainer, "doveadm", "auth", "test", normalizedEmail, password],
{ allowNonZero: true }
);
const combinedOutput = `${result.stdout}\n${result.stderr}`.trim();
if (result.exitCode !== 0 && looksLikeInfrastructureFailure(combinedOutput)) {
throw new HttpError(502, "Mail authentication backend is unavailable.");
}
return result.exitCode === 0;
export const updateMailUserPassword = async (email: string, password: string): Promise<void> => {
await runDocker(["setup", "email", "update", email, password]);
};
export const listInboxFiles = async (email: string): Promise<string[]> => {
const root = buildMaildirRoot(email);
const result = await runDocker([
"exec",
config.mailserverContainer,
"find",
`${root}/cur`,
`${root}/new`,
"-maxdepth",
"1",
"-type",
"f"
]);
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort()
.reverse();
export const deleteMailUser = async (email: string): Promise<void> => {
await runDocker(["setup", "email", "del", email]);
};
export const readMailboxFile = async (containerPath: string): Promise<string> => {
if (!containerPath.startsWith("/var/mail/")) {
throw new HttpError(400, "Invalid mail file path.");
}
const result = await runDocker(["exec", config.mailserverContainer, "cat", containerPath]);
return result.stdout;
export const sendMail = async (rawMessage: string): Promise<void> => {
await runDocker(["sendmail", "-t", "-i"], rawMessage);
};
export const sendMessage = async (sender: string, recipients: string[], rawMessage: string): Promise<void> => {
if (!recipients.length) {
throw new HttpError(400, "At least one recipient is required.");
}
await runDocker(
["exec", "-i", config.mailserverContainer, "sendmail", "-i", "-f", sender, ...recipients],
{ stdinText: rawMessage }
);
};
export const addForwardingAlias = async (sourceEmail: string, targetEmail: string): Promise<void> => {
await runDocker(["exec", config.mailserverContainer, "setup", "alias", "add", sourceEmail, targetEmail]);
};
export const removeForwardingAlias = async (sourceEmail: string, targetEmail: string): Promise<void> => {
await runDocker(["exec", config.mailserverContainer, "setup", "alias", "del", sourceEmail, targetEmail], {
allowNonZero: true
});
};
export const getSupportedFolder = (): string => MAILDIR_FOLDER;
+49 -46
View File
@@ -1,67 +1,70 @@
import { spawn } from "node:child_process";
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
type DockerRunOptions = {
stdinText?: string;
allowNonZero?: boolean;
};
export type DockerRunResult = {
type DockerResult = {
stdout: string;
stderr: string;
exitCode: number;
};
export class DockerCommandError extends Error {
readonly exitCode: number;
readonly stderr: string;
constructor(message: string, exitCode: number, stderr: string) {
super(message);
this.name = "DockerCommandError";
this.exitCode = exitCode;
this.stderr = stderr;
}
}
export const runDocker = async (args: string[], options?: DockerRunOptions): Promise<DockerRunResult> => {
const process = Bun.spawn({
cmd: ["docker", ...args],
stdout: "pipe",
stderr: "pipe",
stdin: options?.stdinText ? "pipe" : "ignore",
env: {}
});
const timeout = setTimeout(() => {
process.kill();
}, config.mailserverTimeoutMs);
export const runDocker = async (
args: string[],
input?: string
): Promise<DockerResult> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.mailserverTimeoutMs);
try {
if (options?.stdinText && process.stdin) {
await process.stdin.write(new TextEncoder().encode(options.stdinText));
await process.stdin.end();
const child = spawn(
config.dockerBinary,
["exec", ...(input ? ["-i"] : []), config.mailserverContainer, ...args],
{
shell: false,
signal: controller.signal,
stdio: ["pipe", "pipe", "pipe"]
}
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
if (input) {
child.stdin.write(input);
}
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited
]);
child.stdin.end();
if (exitCode !== 0 && !options?.allowNonZero) {
throw new DockerCommandError("Docker command failed.", exitCode, stderr);
const exitCode = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => resolve(code ?? 1));
});
if (exitCode !== 0) {
throw new HttpError(502, stderr.trim() || stdout.trim() || "Docker Mailserver command failed.");
}
return { stdout, stderr, exitCode };
return {
stdout,
stderr
};
} catch (error) {
if (error instanceof DockerCommandError) {
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(504, "Docker command timed out or failed to execute.", {
expose: false
});
if (error instanceof Error && error.name === "AbortError") {
throw new HttpError(504, "Docker Mailserver command timed out.");
}
throw new HttpError(502, "Docker Mailserver is unreachable.");
} finally {
clearTimeout(timeout);
}
+102 -146
View File
@@ -1,21 +1,43 @@
import { Elysia } from "elysia";
import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service";
import { HttpError } from "./lib/errors";
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, seedAdminAccount } from "./auth/service";
import { cleanupDatabase } from "./lib/db";
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config";
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security";
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation";
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service";
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service";
import {
createUserSchema,
loginSchema,
parseRouteEmail,
resetPasswordSchema,
resetTokenSchema,
updatePasswordSchema
} from "./lib/validation";
import {
consumePasswordReset,
createUser,
deleteUser,
inspectPasswordReset,
listUsers,
triggerPasswordReset,
updateUserPassword
} from "./users/service";
const app = new Elysia();
const route = (handler: (request: Request, params?: Record<string, string>) => Promise<Response> | Response) =>
const route = (
handler: (context: {
request: Request;
params: Record<string, string>;
}) => Promise<Response> | Response
) =>
async ({ request, params }: { request: Request; params: Record<string, string> }) => {
if (request.method === "OPTIONS") {
return optionsResponse(request);
}
try {
assertAllowedOrigin(request);
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs);
return await handler(request, params);
cleanupDatabase();
return await handler({ request, params });
} catch (error) {
return errorResponse(request, error);
}
@@ -23,179 +45,113 @@ const route = (handler: (request: Request, params?: Record<string, string>) => P
await seedAdminAccount();
app.onRequest(({ request }) => {
if (request.method === "OPTIONS") {
return emptyResponse(request, 204);
}
});
app.get(
"/api/health",
route(async (request) =>
route(async ({ request }) =>
jsonResponse(request, 200, {
ok: true,
mode: config.nodeEnv,
folder: getMailboxFolderLabel()
ok: true
})
)
);
app.get(
"/api/auth/session",
route(async (request) => {
try {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, { session: session.session });
} catch {
return jsonResponse(request, 200, { session: getAnonymousSession() });
}
})
route(async ({ request }) =>
jsonResponse(request, 200, {
session: getSessionFromRequest(request)
})
)
);
app.post(
"/api/auth/login",
route(async (request) => {
if (!config.allowInsecureLogin) {
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
}
route(async ({ request }) => {
const body = await parseJson(request, loginSchema);
const result = await login(body.email, body.password, body.totpCode, request);
const result = await loginAdmin(body.email, body.password);
return jsonResponse(
request,
200,
{ session: result.session }
{ session: result.session },
{ "set-cookie": result.cookie }
);
})
);
app.post(
"/api/auth/logout",
route(async (request) => {
return jsonResponse(request, 200, { session: getAnonymousSession() });
})
);
app.post(
"/api/auth/totp/setup",
route(async (request) => {
const session = await getSessionFromRequest(request);
const setup = await createTotpSecret(session.email);
return jsonResponse(request, 200, setup);
})
);
app.post(
"/api/auth/totp/enable",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, totpCodeSchema);
await enableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
);
app.post(
"/api/auth/totp/disable",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, totpCodeSchema);
await disableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
route(async ({ request }) =>
jsonResponse(
request,
200,
{ session: { authenticated: false, adminEmail: null } },
{ "set-cookie": logoutAdmin(request) }
)
)
);
app.get(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getProfileSettings(session.email));
"/api/users",
route(async ({ request }) => {
requireAdminSession(request);
return jsonResponse(request, 200, { items: await listUsers() });
})
);
app.post(
"/api/users",
route(async ({ request }) => {
const session = requireAdminSession(request);
const body = await parseJson(request, createUserSchema);
const result = await createUser(body.email, session.adminEmail);
return jsonResponse(request, 201, result);
})
);
app.patch(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, profileSchema);
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
"/api/users/:email/password",
route(async ({ request, params }) => {
requireAdminSession(request);
const email = parseRouteEmail(params.email);
const body = await parseJson(request, updatePasswordSchema);
await updateUserPassword(email, body.password);
return emptyResponse(request, 204);
})
);
app.delete(
"/api/users/:email",
route(async ({ request, params }) => {
requireAdminSession(request);
const email = parseRouteEmail(params.email);
await deleteUser(email);
return emptyResponse(request, 204);
})
);
app.post(
"/api/settings/avatar",
route(async (request) => {
const session = await getSessionFromRequest(request);
const formData = await request.formData();
const avatar = formData.get("avatar");
if (!(avatar instanceof File)) {
throw new HttpError(400, "Avatar upload is required.");
}
const profile = await saveAvatar(session.email, avatar);
return jsonResponse(request, 200, profile);
})
);
app.get(
"/api/settings/avatar/:email",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
const targetEmail = strictEmailSchema.parse(params?.email ?? "");
if (targetEmail !== session.email && session.role !== "admin") {
throw new HttpError(403, "You can only access your own avatar.");
}
const file = getAvatarFile(targetEmail);
return fileResponse(request, file, file.type || "application/octet-stream");
})
);
app.get(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getForwardingSettings(session.email));
})
);
app.put(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
const body = await parseJson(request, forwardingSchema);
const rule = await updateForwarding(session.email, body.enabled, body.target);
return jsonResponse(request, 200, rule);
})
);
app.get(
"/api/mail/messages",
route(async (request) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "messages", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const items = await listMessagesForMailbox(session.email);
return jsonResponse(request, 200, { items });
})
);
app.get(
"/api/mail/messages/:id",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "message-detail", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const { id } = messageIdSchema.parse({ id: params?.id ?? "" });
const item = await getMessageForMailbox(session.email, id);
return jsonResponse(request, 200, item);
"/api/users/:email/password-reset",
route(async ({ request, params }) => {
const session = requireAdminSession(request);
const email = parseRouteEmail(params.email);
const result = await triggerPasswordReset(email, session.adminEmail);
return jsonResponse(request, 200, result);
})
);
app.post(
"/api/mail/send",
route(async (request) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
const body = await parseJson(request, composeSchema);
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
"/api/password-resets/inspect",
route(async ({ request }) => {
const body = await parseJson(request, resetTokenSchema);
return jsonResponse(request, 200, await inspectPasswordReset(body.token));
})
);
app.post(
"/api/password-resets/consume",
route(async ({ request }) => {
const body = await parseJson(request, resetPasswordSchema);
await consumePasswordReset(body.token, body.password);
return emptyResponse(request, 204);
})
);
@@ -205,4 +161,4 @@ app.listen({
port: config.port
});
console.log(`Secure webmail API listening on http://${config.host}:${config.port}`);
console.log(`Mail admin API listening on http://${config.host}:${config.port}`);
+29 -66
View File
@@ -1,73 +1,36 @@
import { resolve } from "node:path";
import { z } from "zod";
const normalizedEnv = Object.fromEntries(
Object.entries(Bun.env).map(([key, value]) => [key, value === "" ? undefined : value])
);
const env = Bun.env;
const splitList = (value: string | undefined, fallback: string): string[] =>
(value ?? fallback)
const parseList = (value: string | undefined, fallback: string[]): string[] =>
(value ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
.map((item) => item.trim().toLowerCase())
.filter(Boolean)
.concat(fallback)
.filter((item, index, items) => items.indexOf(item) === index);
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
APP_HOST: z.string().default("0.0.0.0"),
APP_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
APP_ORIGINS: z.string().default("http://localhost:5173"),
APP_EXTERNAL_BASE_URL: z.string().url().default("http://localhost:5173"),
DATA_DIR: z.string().default("./data"),
MAILSERVER_CONTAINER: z
.string()
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/)
.default("mailserver"),
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
HOSTED_DOMAINS: z.string().default("example.com"),
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240),
ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90),
RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60),
LOGIN_RATE_LIMIT: z.coerce.number().int().min(3).max(20).default(5),
LOGIN_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86400).default(600),
MAX_MESSAGES: z.coerce.number().int().min(1).max(100).default(25),
AVATAR_MAX_BYTES: z.coerce.number().int().min(1024).max(5_000_000).default(2_097_152),
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
APP_ADMIN_EMAIL: z.string().email().optional(),
APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(),
TOTP_ENCRYPTION_KEY: z.string().optional(),
ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false")
});
const raw = envSchema.parse(normalizedEnv);
if ((raw.APP_ADMIN_EMAIL && !raw.APP_ADMIN_PASSWORD) || (!raw.APP_ADMIN_EMAIL && raw.APP_ADMIN_PASSWORD)) {
throw new Error("APP_ADMIN_EMAIL and APP_ADMIN_PASSWORD must be provided together.");
}
const parseNumber = (value: string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
export const config = {
nodeEnv: raw.NODE_ENV,
host: raw.APP_HOST,
port: raw.APP_PORT,
externalBaseUrl: raw.APP_EXTERNAL_BASE_URL,
allowedOrigins: new Set(splitList(raw.APP_ORIGINS, "http://localhost:5173")),
dataDir: resolve(process.cwd(), raw.DATA_DIR),
avatarDir: resolve(process.cwd(), raw.DATA_DIR, "avatars"),
dbPath: resolve(process.cwd(), raw.DATA_DIR, "app.db"),
mailserverContainer: raw.MAILSERVER_CONTAINER,
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
routeRateLimit: raw.ROUTE_RATE_LIMIT,
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
loginRateLimit: raw.LOGIN_RATE_LIMIT,
loginWindowMs: raw.LOGIN_WINDOW_SECONDS * 1000,
maxMessages: raw.MAX_MESSAGES,
avatarMaxBytes: raw.AVATAR_MAX_BYTES,
trustProxy: raw.TRUST_PROXY === "true",
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
adminPassword: raw.APP_ADMIN_PASSWORD ?? null,
totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null,
allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true"
} as const;
nodeEnv: env.NODE_ENV ?? "development",
host: env.APP_HOST ?? "0.0.0.0",
port: parseNumber(env.APP_PORT, 3001),
appOrigins: parseList(env.APP_ORIGINS, ["http://localhost:5173"]),
externalBaseUrl: (env.APP_EXTERNAL_BASE_URL ?? "http://localhost:5173").replace(/\/+$/, ""),
dbPath: resolve(process.cwd(), env.APP_DATA_PATH ?? "../../data/app.db"),
mailserverContainer: env.MAILSERVER_CONTAINER ?? "mailserver",
dockerBinary: env.DOCKER_BINARY ?? "docker",
mailserverTimeoutMs: parseNumber(env.MAILSERVER_TIMEOUT_MS, 10_000),
hostedDomains: parseList(env.HOSTED_DOMAINS, []),
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
isProduction: (env.NODE_ENV ?? "development") === "production"
};
-60
View File
@@ -1,60 +0,0 @@
import { Buffer } from "node:buffer";
import { config } from "./config";
import { HttpError } from "./errors";
const encoder = new TextEncoder();
let cachedAesKey: CryptoKey | null = null;
const importAesKey = async (): Promise<CryptoKey> => {
if (!config.totpEncryptionKey) {
throw new HttpError(503, "Two-factor encryption key is not configured.", { expose: true });
}
if (cachedAesKey) {
return cachedAesKey;
}
const rawKey = Buffer.from(config.totpEncryptionKey, "base64");
if (rawKey.byteLength !== 32) {
throw new Error("TOTP_ENCRYPTION_KEY must decode to 32 bytes.");
}
cachedAesKey = await crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt", "decrypt"]);
return cachedAesKey;
};
export const randomToken = (bytes = 32): string => Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
export const sha256Hex = async (value: string): Promise<string> => {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return Buffer.from(digest).toString("hex");
};
export const encryptAtRest = async (value: string): Promise<string> => {
const key = await importAesKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encoder.encode(value)
);
return `${Buffer.from(iv).toString("base64")}.${Buffer.from(encrypted).toString("base64")}`;
};
export const decryptAtRest = async (payload: string): Promise<string> => {
const [ivPart, dataPart] = payload.split(".");
if (!ivPart || !dataPart) {
throw new Error("Invalid encrypted payload.");
}
const key = await importAesKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: Buffer.from(ivPart, "base64") },
key,
Buffer.from(dataPart, "base64")
);
return Buffer.from(decrypted).toString("utf8");
};
+135 -121
View File
@@ -1,20 +1,8 @@
import { mkdirSync } from "node:fs";
import { unlink } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { dirname } from "node:path";
import { Database } from "bun:sqlite";
import { config } from "./config";
export type ProfileRow = {
email: string;
display_name: string;
avatar_path: string | null;
role: "admin" | "user";
totp_secret: string | null;
totp_enabled: number;
created_at: number;
updated_at: number;
};
type AdminRow = {
email: string;
password_hash: string;
@@ -22,31 +10,28 @@ type AdminRow = {
updated_at: number;
};
type ForwardingRow = {
email: string;
target_email: string | null;
enabled: number;
updated_at: number;
type SessionRow = {
token_hash: string;
admin_email: string;
expires_at: number;
created_at: number;
};
export type PasswordResetTokenRow = {
token_hash: string;
user_email: string;
created_by: string;
expires_at: number;
used_at: number | null;
created_at: number;
};
mkdirSync(dirname(config.dbPath), { recursive: true });
mkdirSync(config.avatarDir, { recursive: true });
const db = new Database(config.dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS user_profiles (
email TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_path TEXT,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_admins (
email TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
@@ -54,111 +39,140 @@ db.exec(`
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS forwarding_rules (
email TEXT PRIMARY KEY,
target_email TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
CREATE TABLE IF NOT EXISTS app_sessions (
token_hash TEXT PRIMARY KEY,
admin_email TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_email TEXT NOT NULL,
created_by TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
ON app_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_email
ON password_reset_tokens(user_email);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
ON password_reset_tokens(expires_at);
`);
const defaultDisplayName = (email: string): string => email.split("@")[0] ?? email;
const toAbsoluteAvatarPath = (avatarPath: string | null): string | null =>
avatarPath ? (isAbsolute(avatarPath) ? avatarPath : resolve(process.cwd(), avatarPath)) : null;
const getProfileStatement = db.query("SELECT * FROM user_profiles WHERE email = ?1");
const insertProfileStatement = db.query(`
INSERT OR IGNORE INTO user_profiles (
email,
display_name,
avatar_path,
role,
totp_secret,
totp_enabled,
created_at,
updated_at
) VALUES (?1, ?2, NULL, ?3, NULL, 0, ?4, ?4)
`);
const updateProfileStatement = db.query(`
UPDATE user_profiles
SET display_name = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateAvatarStatement = db.query(`
UPDATE user_profiles
SET avatar_path = ?1, updated_at = ?2
WHERE email = ?3
`);
const updateTotpStatement = db.query(`
UPDATE user_profiles
SET totp_secret = ?1, totp_enabled = ?2, updated_at = ?3
WHERE email = ?4
`);
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
const upsertAdminStatement = db.query(`
INSERT INTO app_admins (email, password_hash, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
ON CONFLICT(email) DO UPDATE SET
password_hash = excluded.password_hash,
updated_at = excluded.updated_at
`);
const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
const upsertForwardingStatement = db.query(`
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
const insertSessionStatement = db.query(`
INSERT INTO app_sessions (token_hash, admin_email, expires_at, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(email) DO UPDATE SET target_email = excluded.target_email, enabled = excluded.enabled, updated_at = excluded.updated_at
`);
const getSessionStatement = db.query("SELECT * FROM app_sessions WHERE token_hash = ?1");
const deleteSessionStatement = db.query("DELETE FROM app_sessions WHERE token_hash = ?1");
const deleteExpiredSessionsStatement = db.query("DELETE FROM app_sessions WHERE expires_at <= ?1");
const insertResetTokenStatement = db.query(`
INSERT INTO password_reset_tokens (token_hash, user_email, created_by, expires_at, used_at, created_at)
VALUES (?1, ?2, ?3, ?4, NULL, ?5)
`);
const deleteResetTokenStatement = db.query("DELETE FROM password_reset_tokens WHERE token_hash = ?1");
const deleteActiveResetTokensForUserStatement = db.query(`
DELETE FROM password_reset_tokens
WHERE user_email = ?1
AND used_at IS NULL
`);
const getActiveResetTokenStatement = db.query(`
SELECT * FROM password_reset_tokens
WHERE token_hash = ?1
AND used_at IS NULL
AND expires_at > ?2
`);
const claimResetTokenStatement = db.query(`
UPDATE password_reset_tokens
SET used_at = ?2
WHERE token_hash = ?1
AND used_at IS NULL
AND expires_at > ?3
`);
const releaseResetTokenStatement = db.query(`
UPDATE password_reset_tokens
SET used_at = NULL
WHERE token_hash = ?1
`);
const deleteExpiredResetTokensStatement = db.query(`
DELETE FROM password_reset_tokens
WHERE expires_at <= ?1
OR (used_at IS NOT NULL AND used_at <= ?2)
`);
export const getProfile = (email: string): ProfileRow | null => (getProfileStatement.get(email) as ProfileRow | null) ?? null;
export const ensureProfile = (email: string, role: "admin" | "user" = "user"): ProfileRow => {
const now = Date.now();
insertProfileStatement.run(email, defaultDisplayName(email), role, now);
return getProfile(email)!;
};
export const updateProfile = (email: string, displayName: string): ProfileRow => {
updateProfileStatement.run(displayName, Date.now(), email);
return getProfile(email)!;
};
export const setAvatarPath = (email: string, avatarPath: string | null): ProfileRow => {
updateAvatarStatement.run(avatarPath, Date.now(), email);
return getProfile(email)!;
};
export const setTotpSecret = (email: string, secret: string | null, enabled: boolean): ProfileRow => {
updateTotpStatement.run(secret, enabled ? 1 : 0, Date.now(), email);
return getProfile(email)!;
};
export const getAdmin = (email: string): AdminRow | null => (getAdminStatement.get(email) as AdminRow | null) ?? null;
export const getAdmin = (email: string): AdminRow | null =>
(getAdminStatement.get(email) as AdminRow | null) ?? null;
export const upsertAdmin = (email: string, passwordHash: string): void => {
upsertAdminStatement.run(email, passwordHash, Date.now());
ensureProfile(email, "admin");
};
export const getForwardingRule = (email: string): ForwardingRow | null =>
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
export const upsertForwardingRule = (email: string, targetEmail: string | null, enabled: boolean): ForwardingRow => {
const now = Date.now();
upsertForwardingStatement.run(email, targetEmail, enabled ? 1 : 0, now);
return getForwardingRule(email)!;
upsertAdminStatement.run(email, passwordHash, now);
};
export const deleteAvatarFile = async (avatarPath: string | null): Promise<void> => {
if (!avatarPath) {
return;
}
const absolutePath = toAbsoluteAvatarPath(avatarPath);
if (!absolutePath || !absolutePath.startsWith(config.avatarDir)) {
return;
}
try {
await unlink(absolutePath);
} catch {
// Best-effort cleanup of replaced avatars.
}
export const createSession = (tokenHash: string, adminEmail: string, expiresAt: number): void => {
insertSessionStatement.run(tokenHash, adminEmail, expiresAt, Date.now());
};
export const getSession = (tokenHash: string): SessionRow | null =>
(getSessionStatement.get(tokenHash) as SessionRow | null) ?? null;
export const deleteSession = (tokenHash: string): void => {
deleteSessionStatement.run(tokenHash);
};
export const createPasswordResetToken = (input: {
tokenHash: string;
userEmail: string;
createdBy: string;
expiresAt: number;
}): void => {
insertResetTokenStatement.run(
input.tokenHash,
input.userEmail,
input.createdBy,
input.expiresAt,
Date.now()
);
};
export const deletePasswordResetToken = (tokenHash: string): void => {
deleteResetTokenStatement.run(tokenHash);
};
export const deleteActivePasswordResetTokensForUser = (userEmail: string): void => {
deleteActiveResetTokensForUserStatement.run(userEmail);
};
export const getActivePasswordResetToken = (tokenHash: string): PasswordResetTokenRow | null =>
(getActiveResetTokenStatement.get(tokenHash, Date.now()) as PasswordResetTokenRow | null) ?? null;
export const claimPasswordResetToken = (tokenHash: string): boolean => {
const usedAt = Date.now();
const result = claimResetTokenStatement.run(tokenHash, usedAt, usedAt);
return result.changes > 0;
};
export const releasePasswordResetToken = (tokenHash: string): void => {
releaseResetTokenStatement.run(tokenHash);
};
export const cleanupDatabase = (): void => {
const now = Date.now();
deleteExpiredSessionsStatement.run(now);
deleteExpiredResetTokensStatement.run(now, now - 86_400_000);
};
+1 -9
View File
@@ -1,16 +1,8 @@
export class HttpError extends Error {
readonly status: number;
readonly expose: boolean;
readonly details?: unknown;
constructor(status: number, message: string, options?: { expose?: boolean; details?: unknown }) {
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
this.expose = options?.expose ?? true;
this.details = options?.details;
}
}
export const isHttpError = (error: unknown): error is HttpError => error instanceof HttpError;
+88 -47
View File
@@ -1,77 +1,118 @@
import { ZodError, type ZodSchema } from "zod";
import { config } from "./config";
import { HttpError, isHttpError } from "./errors";
import { buildResponseHeaders } from "./security";
import { HttpError } from "./errors";
export const jsonResponse = (request: Request, status: number, payload: unknown, init?: HeadersInit): Response =>
const allowedOrigin = (request: Request): string | null => {
const origin = request.headers.get("origin");
if (!origin) {
return null;
}
return config.appOrigins.includes(origin.toLowerCase()) ? origin : null;
};
const securityHeaders = (): Record<string, string> => ({
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer"
});
const mergeHeaders = (request: Request, headers?: HeadersInit): Headers => {
const result = new Headers(headers);
const origin = allowedOrigin(request);
for (const [key, value] of Object.entries(securityHeaders())) {
result.set(key, value);
}
result.set("Vary", "Origin");
if (origin) {
result.set("Access-Control-Allow-Origin", origin);
result.set("Access-Control-Allow-Credentials", "true");
result.set("Access-Control-Allow-Headers", "Content-Type");
result.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
}
return result;
};
export const assertAllowedOrigin = (request: Request): void => {
const origin = request.headers.get("origin");
if (!origin) {
return;
}
if (!config.appOrigins.includes(origin.toLowerCase())) {
throw new HttpError(403, "Origin is not allowed.");
}
};
export const jsonResponse = (
request: Request,
status: number,
payload: unknown,
headers?: HeadersInit
): Response =>
new Response(JSON.stringify(payload), {
status,
headers: buildResponseHeaders(request, {
"Content-Type": "application/json; charset=utf-8",
...init
headers: mergeHeaders(request, {
"content-type": "application/json; charset=utf-8",
...headers
})
});
export const emptyResponse = (request: Request, status = 204, init?: HeadersInit): Response =>
export const emptyResponse = (request: Request, status = 204, headers?: HeadersInit): Response =>
new Response(null, {
status,
headers: buildResponseHeaders(request, init)
headers: mergeHeaders(request, headers)
});
export const fileResponse = (request: Request, body: Blob | Uint8Array, contentType: string, init?: HeadersInit): Response =>
new Response(body as BodyInit, {
status: 200,
headers: buildResponseHeaders(request, {
"Content-Type": contentType,
"Cache-Control": "private, max-age=300",
...init
})
});
export const optionsResponse = (request: Request): Response =>
emptyResponse(request, 204);
export const parseJson = async <T>(request: Request, schema: { parse(data: unknown): T }): Promise<T> => {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
throw new HttpError(415, "Expected application/json request body.");
}
export const parseJson = async <T>(request: Request, schema: ZodSchema<T>): Promise<T> => {
let body: unknown;
let payload: unknown;
try {
payload = await request.json();
body = await request.json();
} catch {
throw new HttpError(400, "Malformed JSON request body.");
throw new HttpError(400, "Request body must be valid JSON.");
}
try {
return schema.parse(payload);
return schema.parse(body);
} catch (error) {
throw new HttpError(400, "Request validation failed.", { details: error });
if (error instanceof ZodError) {
throw new HttpError(400, error.issues[0]?.message ?? "Invalid request body.");
}
throw error;
}
};
export const errorResponse = (request: Request, error: unknown): Response => {
if (isHttpError(error)) {
const retryAfterMs =
typeof error.details === "object" &&
error.details !== null &&
"retryAfterMs" in error.details &&
typeof (error.details as { retryAfterMs?: unknown }).retryAfterMs === "number"
? Math.ceil(((error.details as { retryAfterMs: number }).retryAfterMs ?? 0) / 1000)
: undefined;
return jsonResponse(
request,
error.status,
{
error: error.expose ? error.message : "An internal server error occurred."
},
retryAfterMs ? { "Retry-After": String(retryAfterMs) } : undefined
);
if (error instanceof HttpError) {
return jsonResponse(request, error.status, {
error: {
message: error.message
}
});
}
if (config.nodeEnv !== "production") {
console.error(error);
if (error instanceof ZodError) {
return jsonResponse(request, 400, {
error: {
message: error.issues[0]?.message ?? "Invalid request."
}
});
}
console.error(error);
return jsonResponse(request, 500, {
error: "An internal server error occurred."
error: {
message: "Unexpected server error."
}
});
};
+42 -105
View File
@@ -1,119 +1,56 @@
import { config } from "./config";
import { HttpError } from "./errors";
import { createHash, randomBytes } from "node:crypto";
type RateLimitResult = {
allowed: boolean;
retryAfterMs: number;
};
export const hashToken = (value: string): string =>
createHash("sha256").update(value).digest("hex");
type RateBucket = {
count: number;
resetAt: number;
};
export const createOpaqueToken = (size = 32): string =>
randomBytes(size).toString("hex");
export class SlidingWindowLimiter {
private readonly buckets = new Map<string, RateBucket>();
export const createTemporaryPassword = (): string =>
randomBytes(24).toString("base64url");
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 buildCookieValue = (
name: string,
value: string,
options: {
maxAgeSeconds: number;
secure: boolean;
}
}
): string => {
const parts = [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${options.maxAgeSeconds}`
];
export const globalLimiter = new SlidingWindowLimiter();
export const routeLimiter = new SlidingWindowLimiter();
export const loginLimiter = new SlidingWindowLimiter();
if (options.secure) {
parts.push("Secure");
}
export const getClientIp = (request: Request): string => {
if (config.trustProxy) {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]!.trim();
}
return parts.join("; ");
};
const realIp = request.headers.get("x-real-ip");
if (realIp?.trim()) {
return realIp.trim();
export const buildClearedCookieValue = (name: string, secure: boolean): string =>
buildCookieValue(name, "", { maxAgeSeconds: 0, secure });
export const readCookie = (request: Request, name: string): string | null => {
const raw = request.headers.get("cookie");
if (!raw) {
return null;
}
for (const part of raw.split(";")) {
const [cookieName, ...rest] = part.trim().split("=");
if (cookieName === name) {
return decodeURIComponent(rest.join("="));
}
}
return "0.0.0.0";
return null;
};
export const assertAllowedOrigin = (request: Request): void => {
const origin = request.headers.get("origin");
if (!origin) {
return;
}
export const sanitizeHeaderValue = (value: string): string =>
value.replace(/[\r\n]+/g, " ").trim();
if (!config.allowedOrigins.has(origin.toLowerCase())) {
throw new HttpError(403, "Origin is not allowed.");
}
};
export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => {
const headers = new Headers(init);
headers.set("X-Frame-Options", "DENY");
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "no-referrer");
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
headers.set(
"Content-Security-Policy",
[
"default-src 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"object-src 'none'",
"img-src 'self' data: blob:",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"connect-src 'self' https:"
].join("; ")
);
if (config.nodeEnv === "production") {
headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
}
const origin = request.headers.get("origin");
if (origin && config.allowedOrigins.has(origin.toLowerCase())) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email");
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
headers.append("Vary", "Origin");
}
return headers;
};
export const enforceLimiter = (
limiter: SlidingWindowLimiter,
request: Request,
scope: string,
max: number,
windowMs: number,
discriminator?: string
): void => {
const ip = getClientIp(request);
const key = `${scope}:${ip}:${discriminator ?? request.method}`;
const result = limiter.consume(key, max, windowMs);
if (!result.allowed) {
throw new HttpError(429, "Too many requests. Please slow down.", {
details: { retryAfterMs: result.retryAfterMs }
});
}
};
+34 -84
View File
@@ -1,105 +1,55 @@
import { z } from "zod";
import { config } from "./config";
import { HttpError } from "./errors";
const emailRegex =
/^(?=.{1,254}$)(?=.{1,64}@)[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+$/i;
export const strictEmailSchema = z
const baseEmailSchema = z
.string()
.trim()
.toLowerCase()
.regex(emailRegex, "Invalid email address.")
.refine((value) => !value.includes(".."), "Invalid email address.");
.email("Enter a valid email address.");
export const hostedMailboxSchema = strictEmailSchema.refine((email) => {
const domain = email.split("@")[1] ?? "";
return config.hostedDomains.includes(domain);
}, "Mailbox domain is not hosted by this server.");
const hostedDomainSet = new Set(config.hostedDomains);
export const hostedEmailSchema = baseEmailSchema.superRefine((value, ctx) => {
const domain = value.split("@")[1] ?? "";
if (!hostedDomainSet.has(domain)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "The email domain is not managed by this Docker Mailserver."
});
}
});
export const passwordSchema = z
.string()
.min(12, "Password must contain at least 12 characters.")
.max(128, "Password is too long.");
export const loginSchema = z.object({
email: strictEmailSchema,
password: z.string().min(8).max(256),
totpCode: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional()
email: baseEmailSchema,
password: z.string().min(1, "Password is required.")
});
export const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80)
export const createUserSchema = z.object({
email: hostedEmailSchema
});
export const composeSchema = z.object({
to: z.array(strictEmailSchema).min(1).max(10),
subject: z.string().trim().max(160),
textBody: z.string().trim().min(1).max(20_000)
export const updatePasswordSchema = z.object({
password: passwordSchema
});
export const forwardingSchema = z
.object({
enabled: z.boolean(),
target: strictEmailSchema.nullable()
})
.superRefine((value, ctx) => {
if (value.enabled && !value.target) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Forwarding target is required." });
return;
}
});
export const totpCodeSchema = z.object({
code: z.string().trim().regex(/^\d{6}$/)
export const resetTokenSchema = z.object({
token: z.string().trim().min(32, "Invalid reset token.")
});
export const messageIdSchema = z.object({
id: z.string().trim().regex(/^[a-f0-9]{16,64}$/)
export const resetPasswordSchema = z.object({
token: z.string().trim().min(32, "Invalid reset token."),
password: passwordSchema
});
export const ensureForwardingTargetAllowed = (mailboxEmail: string, target: string | null): void => {
if (!target) {
return;
}
export const routeEmailSchema = z.object({
email: hostedEmailSchema
});
if (target === mailboxEmail) {
throw new HttpError(400, "Forwarding to the same mailbox is not allowed.");
}
const domain = target.split("@")[1] ?? "";
if (!config.forwardingAllowedDomains.includes(domain)) {
throw new HttpError(400, "Forwarding target domain is not allowed by policy.");
}
};
type ImageKind = {
ext: string;
mime: string;
};
export const detectImageKind = (bytes: Uint8Array): ImageKind | null => {
if (bytes.byteLength < 12) {
return null;
}
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return { ext: "png", mime: "image/png" };
}
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return { ext: "jpg", mime: "image/jpeg" };
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
return { ext: "gif", mime: "image/gif" };
}
const riff = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
const webp = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
if (riff === "RIFF" && webp === "WEBP") {
return { ext: "webp", mime: "image/webp" };
}
return null;
};
export const parseRouteEmail = (value: string): string =>
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
+19 -194
View File
@@ -1,201 +1,26 @@
import { createHash } from "node:crypto";
import { convert as htmlToText } from "html-to-text";
import PostalMime from "postal-mime";
import sanitizeHtml from "sanitize-html";
import type { MailAttachment, MailMessageDetail, MailMessageSummary } from "@internal-mail/shared";
import { config } from "../lib/config";
import { HttpError } from "../lib/errors";
import { strictEmailSchema } from "../lib/validation";
import { getSupportedFolder, listInboxFiles, readMailboxFile, sendMessage } from "../docker/mailserver";
import { sanitizeHeaderValue } from "../lib/security";
import { sendMail } from "../docker/mailserver";
const parser = new PostalMime();
export const sendPasswordResetEmail = async (email: string, resetLink: string, expiresAt: string): Promise<void> => {
const subject = sanitizeHeaderValue("Set your mailbox password");
const from = sanitizeHeaderValue(config.appMailFrom);
const to = sanitizeHeaderValue(email);
const createMessageId = (containerPath: string): string =>
createHash("sha256").update(containerPath).digest("hex").slice(0, 32);
const toText = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
const addressFieldToString = (value: unknown): string => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
return value.map((entry) => addressFieldToString(entry)).filter(Boolean).join(", ");
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const name = toText(record.name);
const address = toText(record.address);
const text = toText(record.text);
if (name && address) {
return `${name} <${address}>`;
}
return text || address;
}
return "";
};
const addressFieldToList = (value: unknown): string[] => {
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (Array.isArray(value)) {
return value.map((entry) => addressFieldToString(entry)).filter(Boolean);
}
if (value && typeof value === "object") {
const asText = addressFieldToString(value);
return asText ? [asText] : [];
}
return [];
};
const sanitizeEmailHtml = (html: string): string =>
sanitizeHtml(html, {
allowedTags: ["a", "b", "blockquote", "br", "code", "em", "i", "li", "ol", "p", "pre", "strong", "u", "ul"],
allowedAttributes: {
a: ["href", "title", "target", "rel"]
},
allowedSchemes: ["https", "mailto"],
transformTags: {
a: (_tagName: string, attribs: Record<string, string>) => ({
tagName: "a",
attribs: {
href: attribs.href ?? "#",
title: attribs.title ?? "",
target: "_blank",
rel: "noopener noreferrer nofollow"
}
})
}
});
const buildPreview = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, 160);
const toIsoString = (value: unknown): string => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString();
}
if (typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return new Date().toISOString();
};
const normalizeAttachments = (value: unknown): MailAttachment[] => {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => {
const record = entry as Record<string, unknown>;
return {
filename: toText(record.filename) || "attachment",
mimeType: toText(record.mimeType) || "application/octet-stream",
size: typeof record.size === "number" ? record.size : null
};
});
};
const parseMessage = async (containerPath: string, raw: string): Promise<MailMessageDetail> => {
const parsed = (await parser.parse(raw)) as Record<string, unknown>;
const html = typeof parsed.html === "string" ? parsed.html : null;
const safeHtml = html ? sanitizeEmailHtml(html) : null;
const textBody =
toText(parsed.text) ||
(safeHtml
? htmlToText(safeHtml, {
wordwrap: 120,
selectors: [{ selector: "a", options: { ignoreHref: false } }]
}).trim()
: "");
return {
id: createMessageId(containerPath),
from: addressFieldToString(parsed.from) || "Unknown sender",
to: addressFieldToList(parsed.to),
subject: toText(parsed.subject) || "(No subject)",
preview: buildPreview(textBody),
receivedAt: toIsoString(parsed.date),
textBody,
safeHtml,
hasHtml: Boolean(safeHtml),
attachments: normalizeAttachments(parsed.attachments)
};
};
export const listMessagesForMailbox = async (email: string): Promise<MailMessageSummary[]> => {
const files = await listInboxFiles(email);
const items = await Promise.all(
files.slice(0, config.maxMessages).map(async (path) => {
const raw = await readMailboxFile(path);
const parsed = await parseMessage(path, raw);
const summary: MailMessageSummary = {
id: parsed.id,
from: parsed.from,
subject: parsed.subject,
preview: parsed.preview,
receivedAt: parsed.receivedAt,
hasHtml: parsed.hasHtml
};
return summary;
})
);
return items.sort((left, right) => right.receivedAt.localeCompare(left.receivedAt));
};
export const getMessageForMailbox = async (email: string, id: string): Promise<MailMessageDetail> => {
const files = await listInboxFiles(email);
const match = files.find((path) => createMessageId(path) === id);
if (!match) {
throw new HttpError(404, "Message not found.");
}
const raw = await readMailboxFile(match);
return parseMessage(match, raw);
};
export const composeAndSendMessage = async (
sender: string,
recipients: string[],
subject: string,
textBody: string
): Promise<void> => {
const cleanRecipients = recipients.map((email) => strictEmailSchema.parse(email));
const normalizedSender = strictEmailSchema.parse(sender);
const cleanSubject = subject.replace(/[\r\n]+/g, " ").trim();
const cleanText = textBody.replace(/\r\n/g, "\n").trim();
const rawMessage = [
`From: ${normalizedSender}`,
`To: ${cleanRecipients.join(", ")}`,
`Subject: ${cleanSubject || "(No subject)"}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <${crypto.randomUUID()}@${normalizedSender.split("@")[1]}>`,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
const message = [
`From: Mail Admin <${from}>`,
`To: <${to}>`,
`Subject: ${subject}`,
"Content-Type: text/plain; charset=utf-8",
"",
cleanText
].join("\r\n");
"A mailbox password reset was requested for your account.",
"",
`Use this one-time link to choose a new password: ${resetLink}`,
`This link expires at ${expiresAt}.`,
"",
"If you did not expect this message, contact your administrator."
].join("\n");
await sendMessage(normalizedSender, cleanRecipients, rawMessage);
await sendMail(message);
};
export const getMailboxFolderLabel = (): string => getSupportedFolder();
-107
View File
@@ -1,107 +0,0 @@
import { join } from "node:path";
import type { ForwardingRule, ProfileSettings } from "@internal-mail/shared";
import { config } from "../lib/config";
import {
deleteAvatarFile,
ensureProfile,
getForwardingRule,
getProfile,
setAvatarPath,
updateProfile,
upsertForwardingRule
} from "../lib/db";
import { HttpError } from "../lib/errors";
import { randomToken } from "../lib/crypto";
import { detectImageKind, ensureForwardingTargetAllowed } from "../lib/validation";
import { addForwardingAlias, removeForwardingAlias } from "../docker/mailserver";
const toAvatarUrl = (email: string, avatarPath: string | null): string | null =>
avatarPath ? `/api/settings/avatar/${encodeURIComponent(email)}` : null;
export const getProfileSettings = (email: string): ProfileSettings => {
const profile = ensureProfile(email);
return {
email: profile.email,
displayName: profile.display_name,
role: profile.role,
avatarUrl: toAvatarUrl(profile.email, profile.avatar_path),
totpEnabled: Boolean(profile.totp_enabled)
};
};
export const updateDisplayName = (email: string, displayName: string): ProfileSettings => {
const profile = updateProfile(email, displayName);
return {
email: profile.email,
displayName: profile.display_name,
role: profile.role,
avatarUrl: toAvatarUrl(profile.email, profile.avatar_path),
totpEnabled: Boolean(profile.totp_enabled)
};
};
export const saveAvatar = async (email: string, file: File): Promise<ProfileSettings> => {
if (file.size > config.avatarMaxBytes) {
throw new HttpError(413, "Avatar exceeds the configured size limit.");
}
const bytes = new Uint8Array(await file.arrayBuffer());
const imageKind = detectImageKind(bytes);
if (!imageKind) {
throw new HttpError(400, "Only PNG, JPEG, GIF, and WebP avatars are allowed.");
}
const profile = getProfile(email) ?? ensureProfile(email);
const nextFilename = `${randomToken(18)}.${imageKind.ext}`;
const absolutePath = join(config.avatarDir, nextFilename);
await Bun.write(absolutePath, bytes);
await deleteAvatarFile(profile.avatar_path);
const updated = setAvatarPath(email, absolutePath);
return {
email: updated.email,
displayName: updated.display_name,
role: updated.role,
avatarUrl: toAvatarUrl(updated.email, updated.avatar_path),
totpEnabled: Boolean(updated.totp_enabled)
};
};
export const getAvatarFile = (email: string): Bun.BunFile => {
const profile = getProfile(email);
if (!profile?.avatar_path) {
throw new HttpError(404, "Avatar not found.");
}
return Bun.file(profile.avatar_path);
};
export const getForwardingSettings = (email: string): ForwardingRule => {
const current = getForwardingRule(email);
return {
enabled: Boolean(current?.enabled),
target: current?.target_email ?? null,
allowedDomains: config.forwardingAllowedDomains
};
};
export const updateForwarding = async (email: string, enabled: boolean, target: string | null): Promise<ForwardingRule> => {
ensureForwardingTargetAllowed(email, target);
const previous = getForwardingRule(email);
if (previous?.enabled && previous.target_email) {
await removeForwardingAlias(email, previous.target_email);
}
if (enabled && target) {
await addForwardingAlias(email, target);
}
const next = upsertForwardingRule(email, enabled ? target : null, enabled);
return {
enabled: Boolean(next.enabled),
target: next.target_email ?? null,
allowedDomains: config.forwardingAllowedDomains
};
};
-29
View File
@@ -1,29 +0,0 @@
declare module "sanitize-html" {
type SanitizeOptions = {
allowedTags?: string[];
allowedAttributes?: Record<string, string[]>;
allowedSchemes?: string[];
transformTags?: Record<
string,
(tagName: string, attribs: Record<string, string>) => {
tagName: string;
attribs: Record<string, string>;
}
>;
};
export default function sanitizeHtml(value: string, options?: SanitizeOptions): string;
}
declare module "html-to-text" {
export function convert(
html: string,
options?: {
wordwrap?: number;
selectors?: Array<{
selector: string;
options?: Record<string, unknown>;
}>;
}
): string;
}
+143
View File
@@ -0,0 +1,143 @@
import type { CreateUserResponse, MailUser, PasswordResetInspectResponse, PasswordResetTriggerResponse } from "@internal-mail/shared";
import {
addMailUser,
deleteMailUser,
listMailUsers,
updateMailUserPassword
} from "../docker/mailserver";
import { config } from "../lib/config";
import {
claimPasswordResetToken,
createPasswordResetToken,
deleteActivePasswordResetTokensForUser,
deletePasswordResetToken,
getActivePasswordResetToken,
releasePasswordResetToken
} from "../lib/db";
import { HttpError } from "../lib/errors";
import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security";
import { sendPasswordResetEmail } from "../mail/service";
const maskEmail = (email: string): string => {
const [rawLocalPart, rawDomain] = email.split("@");
const localPart = rawLocalPart ?? email;
const domain = rawDomain ?? "";
const maskedLocal = localPart.length <= 2
? `${localPart[0] ?? "*"}*`
: `${localPart.slice(0, 2)}${"*".repeat(Math.max(1, localPart.length - 2))}`;
return `${maskedLocal}@${domain}`;
};
const buildResetLink = (token: string): string =>
`${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`;
const ensureUserExists = async (email: string): Promise<MailUser> => {
const users = await listMailUsers();
const existing = users.find((item) => item.email === email);
if (!existing) {
throw new HttpError(404, "Mailbox user not found.");
}
return existing;
};
export const listUsers = async (): Promise<MailUser[]> =>
listMailUsers();
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
const temporaryPassword = createTemporaryPassword();
await addMailUser(email, temporaryPassword);
const user = await ensureUserExists(email);
try {
const reset = await triggerPasswordReset(email, createdBy);
return {
user,
resetLinkSent: true,
resetLinkExpiresAt: reset.expiresAt,
warning: null
};
} catch {
return {
user,
resetLinkSent: false,
resetLinkExpiresAt: null,
warning: "Mailbox created, but the setup email could not be sent. Trigger another reset link after mail delivery is fixed."
};
}
};
export const updateUserPassword = async (email: string, password: string): Promise<void> => {
await ensureUserExists(email);
await updateMailUserPassword(email, password);
};
export const deleteUser = async (email: string): Promise<void> => {
await ensureUserExists(email);
await deleteMailUser(email);
};
export const triggerPasswordReset = async (
email: string,
createdBy: string
): Promise<PasswordResetTriggerResponse> => {
await ensureUserExists(email);
deleteActivePasswordResetTokensForUser(email);
const rawToken = createOpaqueToken();
const tokenHash = hashToken(rawToken);
const expiresAtMs = Date.now() + config.resetTokenTtlMs;
const expiresAt = new Date(expiresAtMs).toISOString();
createPasswordResetToken({
tokenHash,
userEmail: email,
createdBy,
expiresAt: expiresAtMs
});
try {
await sendPasswordResetEmail(email, buildResetLink(rawToken), expiresAt);
} catch (error) {
deletePasswordResetToken(tokenHash);
throw error;
}
return { expiresAt };
};
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
const resetToken = getActivePasswordResetToken(hashToken(token));
if (!resetToken) {
throw new HttpError(400, "This password reset link is invalid or expired.");
}
return {
emailHint: maskEmail(resetToken.user_email),
expiresAt: new Date(resetToken.expires_at).toISOString()
};
};
export const consumePasswordReset = async (token: string, password: string): Promise<void> => {
const tokenHash = hashToken(token);
const resetToken = getActivePasswordResetToken(tokenHash);
if (!resetToken) {
throw new HttpError(400, "This password reset link is invalid or expired.");
}
const claimed = claimPasswordResetToken(tokenHash);
if (!claimed) {
throw new HttpError(400, "This password reset link is invalid or already used.");
}
try {
await updateMailUserPassword(resetToken.user_email, password);
} catch (error) {
releasePasswordResetToken(tokenHash);
throw error;
}
};
-7
View File
@@ -1,13 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM"
]
},
"include": [
"src"
]
}