ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/** Minimal .env loader (no dependency): first match wins, never overrides real env. */
|
||||
function loadDotEnv() {
|
||||
const candidates = [resolve(process.cwd(), ".env"), fileURLToPath(new URL("../../.env", import.meta.url)), fileURLToPath(new URL("../.env", import.meta.url))];
|
||||
for (const file of candidates) {
|
||||
if (!existsSync(file)) continue;
|
||||
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
||||
if (!m || line.trim().startsWith("#")) continue;
|
||||
let v = m[2]!;
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
||||
if (process.env[m[1]!] === undefined) process.env[m[1]!] = v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
loadDotEnv();
|
||||
|
||||
function env(name: string, fallback?: string): string {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
if (fallback === undefined) throw new Error(`Missing required environment variable ${name}`);
|
||||
return fallback;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function bool(name: string, fallback: boolean): boolean {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(v.toLowerCase());
|
||||
}
|
||||
|
||||
function int(name: string, fallback: number): number {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") return fallback;
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n)) throw new Error(`Invalid integer for ${name}: ${v}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
let appSecret = process.env.APP_SECRET ?? "";
|
||||
if (!appSecret || appSecret === "change-me") {
|
||||
if (isProd) {
|
||||
throw new Error("APP_SECRET must be set to a strong random value in production");
|
||||
}
|
||||
appSecret = randomBytes(32).toString("base64");
|
||||
console.warn(
|
||||
"[ihasmail] APP_SECRET not set - using an ephemeral secret (persisted sessions will not survive restarts)",
|
||||
);
|
||||
}
|
||||
|
||||
const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, "");
|
||||
|
||||
export const config = {
|
||||
isProd,
|
||||
appName: env("APP_NAME", "ihasmail"),
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: int("PORT", 8080),
|
||||
stalwartUrl,
|
||||
appSecret,
|
||||
trustProxy: bool("TRUST_PROXY", true),
|
||||
/** "auto" = Secure when the request arrived over https; "1"/"0" to force. */
|
||||
secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(),
|
||||
sessionTtl: int("SESSION_TTL", 12 * 60 * 60),
|
||||
sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60),
|
||||
sessionFile: process.env.SESSION_FILE ?? "",
|
||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||
imageProxy: bool("IMAGE_PROXY", true),
|
||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||
};
|
||||
|
||||
export type Config = typeof config;
|
||||
Reference in New Issue
Block a user