From a3d894730ba04b1f3b6492446f401812e536f92a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:11:38 +0200
Subject: [PATCH 01/11] fix: hardened security, CSP enforcement, SSRF redirect
validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe
sandbox
---
app/[locale]/files/page.tsx | 13 +++++++-
app/admin/policy/page.tsx | 1 +
app/api/fetch-ical/route.ts | 43 +++++++++++++++++++------
components/files/file-preview-modal.tsx | 1 +
components/layout/navigation-rail.tsx | 3 +-
lib/admin/session.ts | 15 ++++++++-
lib/admin/types.ts | 2 ++
lib/smime/smime-verify.ts | 6 ++--
proxy.ts | 2 +-
9 files changed, 69 insertions(+), 17 deletions(-)
diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx
index 330544bd..cb488348 100644
--- a/app/[locale]/files/page.tsx
+++ b/app/[locale]/files/page.tsx
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
+import { usePolicyStore } from "@/stores/policy-store";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
+import { AlertTriangle } from "lucide-react";
export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
+ const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -393,7 +396,15 @@ export default function FilesPage() {
)}
- {supportsFiles === false ? (
+ {!filesEnabled ? (
+
+
+
+
Files feature is disabled by your administrator
+
Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.
+
+
+ ) : supportsFiles === false ? (
diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx
index 4fb09b5f..c2d82dec 100644
--- a/app/admin/policy/page.tsx
+++ b/app/admin/policy/page.tsx
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial
controller.abort(), FETCH_TIMEOUT_MS);
- const response = await fetch(url, {
- signal: controller.signal,
- headers: {
- 'Accept': 'text/calendar, application/ics, text/plain, */*',
- 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
- },
- redirect: 'follow',
- });
+ const MAX_REDIRECTS = 5;
+ let currentUrl = url;
+ let response: Response | undefined;
+
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
+ if (!isValidExternalUrl(currentUrl)) {
+ clearTimeout(timeout);
+ return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
+ }
+
+ response = await fetch(currentUrl, {
+ signal: controller.signal,
+ headers: {
+ 'Accept': 'text/calendar, application/ics, text/plain, */*',
+ 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
+ },
+ redirect: 'manual',
+ });
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location');
+ if (!location) {
+ clearTimeout(timeout);
+ return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
+ }
+ // Resolve relative redirects
+ currentUrl = new URL(location, currentUrl).toString();
+ continue;
+ }
+ break;
+ }
clearTimeout(timeout);
- if (!response.ok) {
+ if (!response || !response.ok) {
return NextResponse.json(
- { error: `Remote server returned ${response.status}` },
+ { error: `Remote server returned ${response?.status ?? 'unknown'}` },
{ status: 502 }
);
}
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index 2ef4d7d3..f18c72c5 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index e72f166a..301ff03c 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -169,6 +169,7 @@ export function NavigationRail({
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
+ const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -246,7 +247,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
- { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
+ { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index 629c6f79..0a69865d 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -116,11 +116,24 @@ export async function clearAdminSessionCookie(): Promise {
/**
* Get the client IP from the request headers.
+ *
+ * Proxies typically *append* to X-Forwarded-For, so the last entry
+ * before our trusted proxy is the most reliable client IP. When a
+ * single reverse proxy sits in front of the app the rightmost entry
+ * is the one added by that proxy. We take the rightmost entry to
+ * avoid trusting attacker-controlled values prepended to the header.
+ *
+ * If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
+ * to the number of trusted proxies (default 1).
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
- return forwarded.split(',')[0].trim();
+ const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
+ const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
+ // Take the entry at position (length - depth), clamped to 0
+ const index = Math.max(0, parts.length - depth);
+ return parts[index] || '0.0.0.0';
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 78797a3e..5454fcdf 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -37,6 +37,7 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
+ filesEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
+ filesEnabled: true,
};
export interface ThemePolicy {
diff --git a/lib/smime/smime-verify.ts b/lib/smime/smime-verify.ts
index f9849dc9..8b6adc53 100644
--- a/lib/smime/smime-verify.ts
+++ b/lib/smime/smime-verify.ts
@@ -1,8 +1,8 @@
/**
* Verify CMS SignedData (opaque signed) and extract the inner content.
*
- * v1 performs cryptographic signature validation and cert validity checks
- * but does NOT implement full trust-chain or revocation validation.
+ * Performs cryptographic signature validation, cert validity checks,
+ * and trust-chain verification.
*/
import * as pkijs from 'pkijs';
@@ -61,7 +61,7 @@ export async function smimeVerify(
const verifyResult = await signedData.verify(
{
signer: 0,
- checkChain: false, // v1: no trust-chain validation
+ checkChain: true,
},
cryptoEngine,
);
diff --git a/proxy.ts b/proxy.ts
index 01a00312..051817f4 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -65,7 +65,7 @@ export function proxy(request: NextRequest) {
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()"
);
- response.headers.set("Content-Security-Policy-Report-Only", csp);
+ response.headers.set("Content-Security-Policy", csp);
return response;
}
From 7102add1943978aeb0b4df9a73d2d2bdf3ae1399 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:13:48 +0200
Subject: [PATCH 02/11] fix: add self-signed certificate detection and update
status messages for S/MIME signatures
---
components/email/smime-status-banner.tsx | 8 +++++++-
lib/smime/smime-verify.ts | 6 ++++++
lib/smime/types.ts | 2 ++
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es/common.json | 1 +
locales/fr/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/nl/common.json | 1 +
locales/pt/common.json | 1 +
locales/ru/common.json | 1 +
12 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/components/email/smime-status-banner.tsx b/components/email/smime-status-banner.tsx
index 4ce318bc..688f1ea4 100644
--- a/components/email/smime-status-banner.tsx
+++ b/components/email/smime-status-banner.tsx
@@ -55,7 +55,13 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
- if (status.signerEmailMatch === false) {
+ if (status.selfSigned) {
+ items.push({
+ icon: ,
+ text: t('status_signed_self_signed'),
+ variant: 'warning',
+ });
+ } else if (status.signerEmailMatch === false) {
items.push({
icon: ,
text: t('status_signed_mismatch'),
diff --git a/lib/smime/smime-verify.ts b/lib/smime/smime-verify.ts
index 8b6adc53..30cb8e14 100644
--- a/lib/smime/smime-verify.ts
+++ b/lib/smime/smime-verify.ts
@@ -108,6 +108,11 @@ export async function smimeVerify(
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
}
+ // Detect self-signed certificates (issuer === subject)
+ const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
+ const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
+ const selfSigned = arraysEqual(issuerDer, subjectDer);
+
return {
mimeBytes: innerContent,
status: {
@@ -117,6 +122,7 @@ export async function smimeVerify(
signatureError,
signerCert: signerPublicCert,
signerEmailMatch,
+ selfSigned,
},
};
}
diff --git a/lib/smime/types.ts b/lib/smime/types.ts
index 0282a196..a3e50f3a 100644
--- a/lib/smime/types.ts
+++ b/lib/smime/types.ts
@@ -55,6 +55,8 @@ export interface SmimeStatus {
signatureError?: string;
signerCert?: SmimePublicCert;
signerEmailMatch?: boolean;
+ /** True when the signer certificate is self-signed (not chained to a trusted CA). */
+ selfSigned?: boolean;
decryptionSuccess?: boolean;
decryptionError?: string;
unsupportedReason?: string;
diff --git a/locales/de/common.json b/locales/de/common.json
index 1d2fbc58..a5251303 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Signatur überprüft",
"status_signed_invalid": "Signaturprüfung fehlgeschlagen",
"status_signed_expired_cert": "Mit einem abgelaufenen Zertifikat signiert",
+ "status_signed_self_signed": "Signatur gültig, aber das Zertifikat ist selbstsigniert (nicht vertrauenswürdig)",
"status_signed_mismatch": "Signatur ist gültig, aber der Unterzeichner stimmt nicht mit dem Absender überein",
"status_unsupported": "Nicht unterstütztes S/MIME-Format",
"auto_import_signer_certs": "Unterzeichnerzertifikate automatisch importieren",
diff --git a/locales/en/common.json b/locales/en/common.json
index 207d8bcb..fad55283 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2391,6 +2391,7 @@
"status_signed_valid": "Signature verified",
"status_signed_invalid": "Signature verification failed",
"status_signed_expired_cert": "Signed with an expired certificate",
+ "status_signed_self_signed": "Signature valid, but certificate is self-signed (not trusted)",
"status_signed_mismatch": "Signature valid, but signer does not match sender",
"status_unsupported": "Unsupported S/MIME format",
"auto_import_signer_certs": "Auto-import signer certificates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 14b27732..efdafc3e 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Firma verificada",
"status_signed_invalid": "La verificación de la firma falló",
"status_signed_expired_cert": "Firmado con un certificado caducado",
+ "status_signed_self_signed": "Firma válida, pero el certificado es autofirmado (no confiable)",
"status_signed_mismatch": "La firma es válida, pero el firmante no coincide con el remitente",
"status_unsupported": "Formato S/MIME no compatible",
"auto_import_signer_certs": "Importar automáticamente certificados de firmantes",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 8dade1f3..9b07a50f 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Signature vérifiée",
"status_signed_invalid": "Échec de la vérification de la signature",
"status_signed_expired_cert": "Signé avec un certificat expiré",
+ "status_signed_self_signed": "Signature valide, mais le certificat est auto-signé (non fiable)",
"status_signed_mismatch": "La signature est valide, mais le signataire ne correspond pas à l'expéditeur",
"status_unsupported": "Format S/MIME non pris en charge",
"auto_import_signer_certs": "Importer automatiquement les certificats des signataires",
diff --git a/locales/it/common.json b/locales/it/common.json
index cfa75d64..32439d5d 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Firma verificata",
"status_signed_invalid": "Verifica della firma non riuscita",
"status_signed_expired_cert": "Firmato con un certificato scaduto",
+ "status_signed_self_signed": "Firma valida, ma il certificato è autofirmato (non attendibile)",
"status_signed_mismatch": "La firma è valida, ma il firmatario non corrisponde al mittente",
"status_unsupported": "Formato S/MIME non supportato",
"auto_import_signer_certs": "Importa automaticamente i certificati dei firmatari",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index bde71a2f..8bf12c9e 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "署名を確認しました",
"status_signed_invalid": "署名の検証に失敗しました",
"status_signed_expired_cert": "期限切れの証明書で署名されています",
+ "status_signed_self_signed": "署名は有効ですが、証明書は自己署名です(信頼されていません)",
"status_signed_mismatch": "署名は有効ですが、署名者が送信者と一致しません",
"status_unsupported": "未対応の S/MIME 形式です",
"auto_import_signer_certs": "署名者証明書を自動インポート",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index ea718189..630d5e41 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Handtekening geverifieerd",
"status_signed_invalid": "Verificatie van de handtekening is mislukt",
"status_signed_expired_cert": "Ondertekend met een verlopen certificaat",
+ "status_signed_self_signed": "Handtekening geldig, maar het certificaat is zelfondertekend (niet vertrouwd)",
"status_signed_mismatch": "Handtekening is geldig, maar de ondertekenaar komt niet overeen met de afzender",
"status_unsupported": "Niet-ondersteund S/MIME-formaat",
"auto_import_signer_certs": "Ondertekenaarcertificaten automatisch importeren",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a669b734..3dd6d2fd 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Assinatura verificada",
"status_signed_invalid": "A verificação da assinatura falhou",
"status_signed_expired_cert": "Assinada com um certificado expirado",
+ "status_signed_self_signed": "Assinatura válida, mas o certificado é autoassinado (não confiável)",
"status_signed_mismatch": "A assinatura é válida, mas o signatário não corresponde ao remetente",
"status_unsupported": "Formato S/MIME não suportado",
"auto_import_signer_certs": "Importar automaticamente certificados de signatários",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index a81c5e4a..65245347 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2384,6 +2384,7 @@
"status_signed_valid": "Подпись проверена",
"status_signed_invalid": "Проверка подписи не удалась",
"status_signed_expired_cert": "Подписано просроченным сертификатом",
+ "status_signed_self_signed": "Подпись действительна, но сертификат самоподписанный (не доверенный)",
"status_signed_mismatch": "Подпись действительна, но подписант не совпадает с отправителем",
"status_unsupported": "Неподдерживаемый формат S/MIME",
"auto_import_signer_certs": "Автоимпорт сертификатов подписантов",
From f6bec519f4d4a85043a937e9119a85098ffdb22f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:18:13 +0200
Subject: [PATCH 03/11] fix: update file feature disabled messages and add
stability warnings
---
app/[locale]/files/page.tsx | 10 ++++++++--
locales/de/common.json | 5 ++++-
locales/en/common.json | 5 ++++-
locales/es/common.json | 5 ++++-
locales/fr/common.json | 5 ++++-
locales/it/common.json | 5 ++++-
locales/ja/common.json | 5 ++++-
locales/nl/common.json | 5 ++++-
locales/pt/common.json | 5 ++++-
locales/ru/common.json | 5 ++++-
10 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx
index cb488348..d785a669 100644
--- a/app/[locale]/files/page.tsx
+++ b/app/[locale]/files/page.tsx
@@ -400,8 +400,8 @@ export default function FilesPage() {
-
Files feature is disabled by your administrator
-
Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.
+
{t("disabled_title")}
+
{t("disabled_description")}
) : supportsFiles === false ? (
@@ -409,6 +409,11 @@ export default function FilesPage() {
{t("not_available")}
) : (
+
+
+
+
{t("stability_warning")}
+
+
)}
diff --git a/locales/de/common.json b/locales/de/common.json
index a5251303..3aa8d0a4 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "Die Dateifunktion wurde von Ihrem Administrator deaktiviert",
+ "disabled_description": "Große Datei-Uploads über WebDAV können Stalwart/RocksDB-Instabilität verursachen, einschließlich Out-of-Memory-Abstürzen und nicht wiederherstellbarer Festplattennutzung. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Blob-Speicher entfernt. Diese Funktion wird für Produktionsumgebungen nicht empfohlen.",
+ "stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden."
},
"smime": {
"your_certificates": "Ihre Zertifikate",
diff --git a/locales/en/common.json b/locales/en/common.json
index fad55283..ea2d03b4 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2322,7 +2322,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "Files feature is disabled by your administrator",
+ "disabled_description": "Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.",
+ "stability_warning": "Large file uploads can cause server instability. Deleted files may not be immediately purged from storage. Use with caution."
},
"smime": {
"your_certificates": "Your Certificates",
diff --git a/locales/es/common.json b/locales/es/common.json
index efdafc3e..0830f97b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "La función de archivos ha sido desactivada por su administrador",
+ "disabled_description": "Las cargas de archivos grandes a través de WebDAV pueden causar inestabilidad en Stalwart/RocksDB, incluyendo errores de memoria y uso irrecuperable del disco. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Esta función no se recomienda para entornos de producción.",
+ "stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución."
},
"smime": {
"your_certificates": "Tus certificados",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 9b07a50f..20a6bbb7 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "La fonctionnalité Fichiers a été désactivée par votre administrateur",
+ "disabled_description": "Les téléchargements de fichiers volumineux via WebDAV peuvent provoquer une instabilité de Stalwart/RocksDB, y compris des crashs de mémoire et une utilisation irrécupérable du disque. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. Cette fonctionnalité n'est pas recommandée pour les environnements de production.",
+ "stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence."
},
"smime": {
"your_certificates": "Vos certificats",
diff --git a/locales/it/common.json b/locales/it/common.json
index 32439d5d..2db79b15 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "La funzionalità File è stata disabilitata dal tuo amministratore",
+ "disabled_description": "I caricamenti di file di grandi dimensioni tramite WebDAV possono causare instabilità di Stalwart/RocksDB, inclusi crash di memoria e utilizzo irrecuperabile del disco. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Questa funzionalità non è consigliata per ambienti di produzione.",
+ "stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela."
},
"smime": {
"your_certificates": "I tuoi certificati",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 8bf12c9e..7826a307 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "ファイル機能は管理者によって無効にされています",
+ "disabled_description": "WebDAV経由の大容量ファイルアップロードは、メモリ不足クラッシュや回復不能なディスク使用量など、Stalwart/RocksDBの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。この機能は本番環境では推奨されません。",
+ "stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。"
},
"smime": {
"your_certificates": "あなたの証明書",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 630d5e41..3a83b9f5 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "De bestandsfunctie is uitgeschakeld door uw beheerder",
+ "disabled_description": "Grote bestandsuploads via WebDAV kunnen Stalwart/RocksDB-instabiliteit veroorzaken, waaronder geheugenfouten en onherstelbaar schijfgebruik. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Deze functie wordt niet aanbevolen voor productieomgevingen.",
+ "stability_warning": "Grote bestandsuploads kunnen serverinstabiliteit veroorzaken. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Gebruik met voorzichtigheid."
},
"smime": {
"your_certificates": "Uw certificaten",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 3dd6d2fd..1f5751cf 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Folder Navigation",
"settings_folder_layout_desc": "Choose how folders are displayed: inline with files or in a sidebar tree",
"settings_folder_layout_inline": "Inline",
- "settings_folder_layout_sidebar": "Sidebar"
+ "settings_folder_layout_sidebar": "Sidebar",
+ "disabled_title": "O recurso de arquivos foi desativado pelo seu administrador",
+ "disabled_description": "Uploads de arquivos grandes via WebDAV podem causar instabilidade no Stalwart/RocksDB, incluindo falhas de memória e uso irrecuperável de disco. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Este recurso não é recomendado para ambientes de produção.",
+ "stability_warning": "Uploads de arquivos grandes podem causar instabilidade no servidor. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Use com cautela."
},
"smime": {
"your_certificates": "Seus certificados",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 65245347..32deeafe 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2315,7 +2315,10 @@
"settings_folder_layout": "Навигация по папкам",
"settings_folder_layout_desc": "Выберите, как отображать папки: встроенно с файлами или в боковом дереве",
"settings_folder_layout_inline": "Встроенно",
- "settings_folder_layout_sidebar": "Боковая панель"
+ "settings_folder_layout_sidebar": "Боковая панель",
+ "disabled_title": "Функция файлов отключена вашим администратором",
+ "disabled_description": "Загрузка больших файлов через WebDAV может вызвать нестабильность Stalwart/RocksDB, включая ошибки нехватки памяти и невосстановимое использование диска. Удалённые файлы могут не быть немедленно удалены из хранилища. Эта функция не рекомендуется для рабочих сред.",
+ "stability_warning": "Загрузка больших файлов может вызвать нестабильность сервера. Удалённые файлы могут не быть немедленно удалены из хранилища. Используйте с осторожностью."
},
"smime": {
"your_certificates": "Ваши сертификаты",
From 1b2ee7da3a50605755121e0fc6ede6e991742fb4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:29:09 +0200
Subject: [PATCH 04/11] fix: prevent orphaning of nested mailboxes by
restricting deduplication to root-level folders
---
lib/__tests__/mailbox-deep-nesting.test.ts | 111 ++++++++++++++-------
lib/utils.ts | 11 +-
2 files changed, 85 insertions(+), 37 deletions(-)
diff --git a/lib/__tests__/mailbox-deep-nesting.test.ts b/lib/__tests__/mailbox-deep-nesting.test.ts
index b10e5c26..3a7eeeb0 100644
--- a/lib/__tests__/mailbox-deep-nesting.test.ts
+++ b/lib/__tests__/mailbox-deep-nesting.test.ts
@@ -263,48 +263,37 @@ describe('mailbox deep nesting (depth 4+)', () => {
});
});
-describe('mailbox deduplication side effects on nesting', () => {
- it('should not remove an intermediate parent whose name matches a role mailbox', () => {
- // Scenario: A non-role folder named "Sent" is an intermediate parent.
- // The deduplication logic might remove it because it matches the role "sent" mailbox name.
+describe('GitHub #118: duplicate subfolder names cause depth-4 orphaning', () => {
+ it('should keep nested folders when a subfolder has the same name as a role mailbox', () => {
+ // Reporter's exact scenario: two subfolders with the same name.
+ // The dedup uses substring matching and removes non-role folders whose name
+ // matches a role folder — even if they're deep in the tree with children.
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
- // A user-created folder also named "Sent" that's a child of Inbox
+ // User-created subfolder also named "Sent" nested under Inbox
makeMailbox({ id: 'sent-custom', name: 'Sent', parentId: 'inbox' }),
- // Child of the custom "Sent" folder
+ // Child of the custom "Sent" folder — becomes orphaned if parent is deduped
makeMailbox({ id: 'sent-child', name: 'Archive', parentId: 'sent-custom' }),
];
const tree = buildMailboxTree(mailboxes);
const flat = flattenMailboxTree(tree);
+ const rootIds = tree.map(n => n.id);
+
+ // sent-custom MUST be kept because it has children — removing it orphans sent-child
+ const sentCustom = flat.find(n => n.id === 'sent-custom');
+ expect(sentCustom).toBeDefined();
+ expect(sentCustom!.depth).toBe(1); // nested under Inbox
- // The custom "Sent" (sent-custom) might be filtered by deduplication.
- // If it IS removed, then "sent-child" loses its parent and becomes root — BAD.
const sentChild = flat.find(n => n.id === 'sent-child');
expect(sentChild).toBeDefined();
-
- // Check if sent-child is orphaned at root (the bug)
- const rootIds = tree.map(n => n.id);
- const isOrphaned = rootIds.includes('sent-child');
-
- if (isOrphaned) {
- // This demonstrates the deduplication bug: removing an intermediate parent
- // causes its children to become orphaned at root level
- console.warn(
- 'BUG DETECTED: Deduplication removed intermediate parent "sent-custom", ' +
- 'orphaning "sent-child" to root level.'
- );
- }
-
- // Document the current behavior (this test is diagnostic)
- // Ideally: sent-child should be nested under sent-custom at depth 2
- // If dedup removes sent-custom: sent-child ends up at root with depth 0
- expect(sentChild!.depth).toBeGreaterThanOrEqual(0);
+ expect(sentChild!.depth).toBe(2); // nested under sent-custom
+ expect(rootIds).not.toContain('sent-child'); // must NOT be orphaned at root
});
- it('should not remove a parent folder whose name is a substring of a role name', () => {
- // Folder named "Draft" (substring of "Drafts" role) used as intermediate parent
+ it('should keep nested folders when name is substring of a role name', () => {
+ // "Draft" is a substring of "Drafts" — dedup removes it, orphaning children
const mailboxes = [
makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
makeMailbox({ id: 'drafts-role', name: 'Drafts', role: 'drafts' }),
@@ -318,16 +307,66 @@ describe('mailbox deduplication side effects on nesting', () => {
const draftChild = flat.find(n => n.id === 'draft-child');
expect(draftChild).toBeDefined();
+ expect(draftChild!.depth).toBe(2);
+ expect(rootIds).not.toContain('draft-child');
+ });
- const isOrphaned = rootIds.includes('draft-child');
- if (isOrphaned) {
- console.warn(
- 'BUG DETECTED: Deduplication removed "draft-folder" (substring match with "Drafts"), ' +
- 'orphaning "draft-child" to root level.'
- );
- }
+ it('should handle the exact reported structure with duplicate names at different depths', () => {
+ // Stalwart allows creating subfolders with the same name at different levels.
+ // If any of those names match a role mailbox name, dedup could remove them.
+ const mailboxes = [
+ makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
+ makeMailbox({ id: 'trash-role', name: 'Trash', role: 'trash' }),
+ makeMailbox({ id: 'privat', name: 'PRIVAT', parentId: 'inbox' }),
+ makeMailbox({ id: 'bookings', name: 'BOOKINGS', parentId: 'privat' }),
+ // User created a subfolder named "Trash" under BOOKINGS (e.g. for old bookings)
+ makeMailbox({ id: 'trash-custom', name: 'Trash', parentId: 'bookings' }),
+ // Depth 4: child of the custom Trash folder
+ makeMailbox({ id: 'restaurant', name: 'RESTAURANT', parentId: 'trash-custom' }),
+ ];
- expect(draftChild!.depth).toBeGreaterThanOrEqual(0);
+ const tree = buildMailboxTree(mailboxes);
+ const flat = flattenMailboxTree(tree);
+ const rootIds = tree.map(n => n.id);
+
+ // RESTAURANT must be at depth 4, not orphaned at root
+ const restaurant = flat.find(n => n.id === 'restaurant');
+ expect(restaurant).toBeDefined();
+ expect(restaurant!.depth).toBe(4);
+ expect(rootIds).not.toContain('restaurant');
+
+ // Custom "Trash" must be kept as it has children
+ const trashCustom = flat.find(n => n.id === 'trash-custom');
+ expect(trashCustom).toBeDefined();
+ expect(trashCustom!.depth).toBe(3);
+ });
+
+ it('should only dedup root-level non-role mailboxes that duplicate role mailboxes', () => {
+ // Dedup should only remove mailboxes that are BOTH:
+ // 1. At root level (no parentId) — same structural position as role mailbox
+ // 2. Name-matching a role mailbox
+ // Nested mailboxes with matching names should always be kept.
+ const mailboxes = [
+ makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }),
+ makeMailbox({ id: 'sent-role', name: 'Sent', role: 'sent' }),
+ makeMailbox({ id: 'sent-dup', name: 'Sent Mail' }), // root-level duplicate — OK to remove
+ makeMailbox({ id: 'proj', name: 'Projects', parentId: 'inbox' }),
+ makeMailbox({ id: 'sent-nested', name: 'Sent', parentId: 'proj' }), // nested — must keep
+ makeMailbox({ id: 'report', name: 'Report', parentId: 'sent-nested' }),
+ ];
+
+ const tree = buildMailboxTree(mailboxes);
+ const flat = flattenMailboxTree(tree);
+
+ // "Sent Mail" at root (no parentId) can be deduped — that's fine
+ // But "Sent" nested under Projects must be kept
+ const sentNested = flat.find(n => n.id === 'sent-nested');
+ expect(sentNested).toBeDefined();
+ expect(sentNested!.depth).toBe(2);
+
+ const report = flat.find(n => n.id === 'report');
+ expect(report).toBeDefined();
+ expect(report!.depth).toBe(3);
});
});
diff --git a/lib/utils.ts b/lib/utils.ts
index 2ab7e721..47f59d2a 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -125,7 +125,16 @@ function deduplicateMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return;
}
- // Check if this is a duplicate of a role-based mailbox in the SAME account
+ // Never deduplicate nested mailboxes — only root-level folders can be
+ // duplicates of role-based mailboxes. Removing a nested folder that happens
+ // to share a name with a role folder (e.g. a subfolder named "Sent") would
+ // orphan its children to root level. (GitHub #118)
+ if (mb.parentId) {
+ result.push(mb);
+ return;
+ }
+
+ // Check if this root-level mailbox is a duplicate of a role-based mailbox in the SAME account
const accountKey = mb.accountId || '';
const accountRoles = rolesByAccount.get(accountKey) || [];
const lowerName = mb.name.toLowerCase();
From 66fe7fd359090883f5ae5a4b397264ec11f5f41c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:56:32 +0200
Subject: [PATCH 05/11] fix: enhance security by blocking plugins with
dangerous JS patterns and enforcing strict session secret length
---
app/api/admin/marketplace/route.ts | 20 ++++++++++++++++++++
app/api/admin/plugins/route.ts | 12 +++++++++---
app/api/auth/session/route.ts | 10 +++++++---
lib/admin/session.ts | 8 ++++++++
lib/auth/crypto.ts | 8 ++++++++
lib/email-sanitization.ts | 5 +++--
lib/plugin-types.ts | 1 +
7 files changed, 56 insertions(+), 8 deletions(-)
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 13abde0f..e57b5868 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
const code = await jsFile.async('string');
+ // Block plugins with dangerous JS patterns
+ const DANGEROUS_JS_PATTERNS = [
+ { pattern: /\beval\s*\(/g, label: 'eval()' },
+ { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
+ { pattern: /document\.cookie/g, label: 'document.cookie' },
+ { pattern: /document\.write/g, label: 'document.write' },
+ { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
+ ];
+ const dangerousFindings: string[] = [];
+ for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
+ if (pattern.test(code)) dangerousFindings.push(label);
+ pattern.lastIndex = 0;
+ }
+ if (dangerousFindings.length > 0) {
+ return NextResponse.json(
+ { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
+ { status: 400 },
+ );
+ }
+
// Validate permissions
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index a253fb93..b1137346 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
}
const code = await entryFile.async('string');
- // Security warnings (logged but not blocking for admin)
+ // Security: block plugins containing dangerous JS patterns
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) warnings.push(`Contains ${label}`);
pattern.lastIndex = 0;
}
+ if (warnings.length > 0) {
+ return NextResponse.json(
+ { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
+ { status: 400 },
+ );
+ }
const now = new Date().toISOString();
const plugin: ServerPlugin = {
@@ -165,9 +171,9 @@ export async function POST(request: NextRequest) {
};
await savePlugin(plugin, code);
- await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip);
+ await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
- return NextResponse.json({ plugin, warnings });
+ return NextResponse.json({ plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 21635efd..40e13f17 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -73,13 +73,17 @@ export async function GET(request: NextRequest) {
/**
* PUT — retrieve full credentials (including password) for session restoration.
- * Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed.
+ * Protected by multiple Sec-Fetch-* headers to ensure only same-origin
+ * browser fetch() requests succeed. Non-browser clients cannot forge these.
*/
export async function PUT(request: NextRequest) {
try {
- // Block non-browser and cross-origin requests
+ // Require all Sec-Fetch-* headers to match a same-origin fetch() call.
+ // Browsers set these automatically and they cannot be overridden by JS.
const secFetchSite = request.headers.get('sec-fetch-site');
- if (secFetchSite !== 'same-origin') {
+ const secFetchMode = request.headers.get('sec-fetch-mode');
+ const secFetchDest = request.headers.get('sec-fetch-dest');
+ if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index 0a69865d..3d91369f 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
+const MIN_SECRET_LENGTH = 32;
+
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
+ if (secret.length < MIN_SECRET_LENGTH) {
+ throw new Error(
+ `SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
+ `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
+ );
+ }
return createHash('sha256').update(secret).digest();
}
diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts
index 6a3d689a..ca0506c9 100644
--- a/lib/auth/crypto.ts
+++ b/lib/auth/crypto.ts
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
+const MIN_SECRET_LENGTH = 32;
+
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
+ if (secret.length < MIN_SECRET_LENGTH) {
+ throw new Error(
+ `SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
+ `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
+ );
+ }
return createHash('sha256').update(secret).digest();
}
diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts
index 9eb67556..c458de89 100644
--- a/lib/email-sanitization.ts
+++ b/lib/email-sanitization.ts
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
- // Allow blob: URIs so authenticated inline images (CID) are not stripped
+ // Allow blob: URIs so authenticated inline images (CID) are not stripped.
+ // data: is restricted to image/* MIME types to prevent SVG script injection.
// eslint-disable-next-line no-useless-escape
- ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index d5536eeb..e53ee087 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -427,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
export const DISALLOWED_CSS_PATTERNS = [
/@import\b/i,
/url\s*\(\s*['"]?https?:/i,
+ /url\s*\(\s*['"]?data:/i,
/expression\s*\(/i,
/javascript\s*:/i,
/-moz-binding/i,
From 0f7638055cf15de45558c95a6c410d1a1a8bcff8 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 31 Mar 2026 16:10:14 +0200
Subject: [PATCH 06/11] fix: auto-focus input fields in email composer for
improved user experience #126
---
components/email/email-composer.tsx | 73 ++++++++++++++++++++++++-----
1 file changed, 62 insertions(+), 11 deletions(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fe07bc4a..1c96cf8e 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -332,6 +332,17 @@ export function EmailComposer({
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
+ // Auto-focus the To field when composing a new email or forwarding
+ useEffect(() => {
+ if (mode === 'forward' || mode === 'compose') {
+ // Small delay to ensure the input is rendered
+ const timer = setTimeout(() => {
+ toInputRef.current?.focus();
+ }, 100);
+ return () => clearTimeout(timer);
+ }
+ }, [mode]);
+
const [autocompleteResults, setAutocompleteResults] = useState>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -339,10 +350,26 @@ export function EmailComposer({
const toInputRef = useRef(null);
const ccInputRef = useRef(null);
const bccInputRef = useRef(null);
+ const subjectInputRef = useRef(null);
+ const bodyRef = useRef(null);
+ const editorContainerRef = useRef(null);
const toDropdownRef = useRef(null);
const ccDropdownRef = useRef(null);
const bccDropdownRef = useRef(null);
+ const focusSubject = useCallback(() => {
+ subjectInputRef.current?.focus();
+ }, []);
+
+ const focusBody = useCallback(() => {
+ if (plainTextMode) {
+ bodyRef.current?.focus();
+ } else {
+ const proseMirror = editorContainerRef.current?.querySelector('.ProseMirror') as HTMLElement | null;
+ proseMirror?.focus();
+ }
+ }, [plainTextMode]);
+
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
@@ -1071,6 +1098,7 @@ export function EmailComposer({
onInsertAutocomplete={insertAutocomplete}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
+ onTab={focusSubject}
/>