This commit is contained in:
Anaïs Saraiva
2026-03-29 18:45:25 +02:00
parent 0f7d692d12
commit afc839d43d
42 changed files with 3809 additions and 58 deletions
+216
View File
@@ -0,0 +1,216 @@
import { Elysia } from "elysia";
import { login, logout, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service";
import { HttpError } from "./lib/errors";
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
import { config } from "./lib/config";
import { assertAllowedOrigin, enforceLimiter, globalLimiter, loginLimiter, routeLimiter } from "./lib/security";
import { composeSchema, forwardingSchema, loginSchema, messageIdSchema, profileSchema, strictEmailSchema, totpCodeSchema } from "./lib/validation";
import { composeAndSendMessage, getMailboxFolderLabel, getMessageForMailbox, listMessagesForMailbox } from "./mail/service";
import { getAvatarFile, getForwardingSettings, getProfileSettings, saveAvatar, updateDisplayName, updateForwarding } from "./settings/service";
const app = new Elysia();
const route = (handler: (request: Request, params?: Record<string, string>) => Promise<Response> | Response) =>
async ({ request, params }: { request: Request; params: Record<string, string> }) => {
try {
assertAllowedOrigin(request);
enforceLimiter(globalLimiter, request, "global", config.globalRateLimit, config.rateLimitWindowMs);
return await handler(request, params);
} catch (error) {
return errorResponse(request, error);
}
};
await seedAdminAccount();
app.onRequest(({ request }) => {
if (request.method === "OPTIONS") {
return emptyResponse(request, 204);
}
});
app.get(
"/api/health",
route(async (request) =>
jsonResponse(request, 200, {
ok: true,
mode: config.nodeEnv,
folder: getMailboxFolderLabel()
})
)
);
app.get(
"/api/auth/session",
route(async (request) => {
try {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, { session: session.session });
} catch {
return jsonResponse(request, 200, { session: getAnonymousSession() });
}
})
);
app.post(
"/api/auth/login",
route(async (request) => {
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
const body = await parseJson(request, loginSchema);
const result = await login(body.email, body.password, body.totpCode, request);
return jsonResponse(
request,
200,
{ session: result.session },
{
"Set-Cookie": result.cookie
}
);
})
);
app.post(
"/api/auth/logout",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const expiredCookie = logout(request);
return jsonResponse(
request,
200,
{ session: getAnonymousSession(), email: session.email },
{ "Set-Cookie": expiredCookie }
);
})
);
app.post(
"/api/auth/totp/setup",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const setup = await createTotpSecret(session.email);
return jsonResponse(request, 200, setup);
})
);
app.post(
"/api/auth/totp/enable",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const body = await parseJson(request, totpCodeSchema);
await enableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
);
app.post(
"/api/auth/totp/disable",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const body = await parseJson(request, totpCodeSchema);
await disableTotp(session.email, body.code);
return emptyResponse(request, 204);
})
);
app.get(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getProfileSettings(session.email));
})
);
app.patch(
"/api/settings/profile",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const body = await parseJson(request, profileSchema);
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
})
);
app.post(
"/api/settings/avatar",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const formData = await request.formData();
const avatar = formData.get("avatar");
if (!(avatar instanceof File)) {
throw new HttpError(400, "Avatar upload is required.");
}
const profile = await saveAvatar(session.email, avatar);
return jsonResponse(request, 200, profile);
})
);
app.get(
"/api/settings/avatar/:email",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
const targetEmail = strictEmailSchema.parse(params?.email ?? "");
if (targetEmail !== session.email && session.role !== "admin") {
throw new HttpError(403, "You can only access your own avatar.");
}
const file = getAvatarFile(targetEmail);
return fileResponse(request, file, file.type || "application/octet-stream");
})
);
app.get(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request);
return jsonResponse(request, 200, getForwardingSettings(session.email));
})
);
app.put(
"/api/settings/forwarding",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
const body = await parseJson(request, forwardingSchema);
const rule = await updateForwarding(session.email, body.enabled, body.target);
return jsonResponse(request, 200, rule);
})
);
app.get(
"/api/mail/messages",
route(async (request) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "messages", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const items = await listMessagesForMailbox(session.email);
return jsonResponse(request, 200, { items });
})
);
app.get(
"/api/mail/messages/:id",
route(async (request, params) => {
const session = await getSessionFromRequest(request);
enforceLimiter(routeLimiter, request, "message-detail", config.routeRateLimit, config.rateLimitWindowMs, session.email);
const { id } = messageIdSchema.parse({ id: params?.id ?? "" });
const item = await getMessageForMailbox(session.email, id);
return jsonResponse(request, 200, item);
})
);
app.post(
"/api/mail/send",
route(async (request) => {
const session = await getSessionFromRequest(request, { requireCsrf: true });
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
const body = await parseJson(request, composeSchema);
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
return emptyResponse(request, 204);
})
);
app.listen({
hostname: config.host,
port: config.port
});
console.log(`Secure webmail API listening on http://${config.host}:${config.port}`);