From 8a2ef5a6c5d737d132c1c6a7e356f32365263e97 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 14:29:29 +0100 Subject: [PATCH 1/8] feat: integrate webcrypto-liner for legacy algorithm support in S/MIME handling --- .env.example | 2 +- lib/smime/crypto-engine.ts | 76 +++++++++++++++ lib/smime/pkcs12-import.ts | 20 ++-- lib/smime/smime-decrypt.ts | 8 +- package-lock.json | 186 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 6 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 lib/smime/crypto-engine.ts diff --git a/.env.example b/.env.example index eaea9c85..8bbbf2cb 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com # ============================================================================= # Hostname the server binds to (default: 0.0.0.0) -# Set to "::" for IPv6 or "[::]" for dual-stack support. +# Set to "::" for dual-stack # HOSTNAME=0.0.0.0 # Port the server listens on (default: 3000) diff --git a/lib/smime/crypto-engine.ts b/lib/smime/crypto-engine.ts new file mode 100644 index 00000000..0879e477 --- /dev/null +++ b/lib/smime/crypto-engine.ts @@ -0,0 +1,76 @@ +/** + * Crypto engine backed by webcrypto-liner for legacy algorithm support. + * + * webcrypto-liner extends the native Web Crypto API with algorithms + * like DES-EDE3-CBC (3DES) that are commonly found in S/MIME messages + * and PKCS#12 files produced by legacy clients (Outlook, Thunderbird, etc.). + * + * Native Web Crypto calls are passed through to the real implementation; + * liner only intercepts algorithms that the browser doesn't natively support. + */ + +import * as pkijs from 'pkijs'; + +// webcrypto-liner exports a Crypto constructor at runtime that extends native +// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only +// expose the type alias, so we import the module dynamically and cast. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const liner = require('webcrypto-liner') as { + Crypto: { new (): Crypto }; + setCrypto: (subtle: SubtleCrypto) => void; + nativeCrypto: Crypto | Record; +}; + +let linerEngine: pkijs.CryptoEngine | null = null; +let linerCryptoInstance: Crypto | null = null; + +function ensureLiner() { + if (!linerCryptoInstance) { + // In Node.js, webcrypto-liner can't auto-detect the native crypto + // (it looks for self.crypto which doesn't exist). Feed it manually + // so that native algorithms (RSA, AES, etc.) stay hardware-accelerated + // and only truly missing algorithms (3DES) use the software fallback. + if ( + typeof liner.nativeCrypto?.getRandomValues !== 'function' && + typeof globalThis.crypto?.subtle !== 'undefined' + ) { + liner.setCrypto(globalThis.crypto.subtle); + } + linerCryptoInstance = new liner.Crypto(); + } + if (!linerEngine) { + linerEngine = new pkijs.CryptoEngine({ + crypto: linerCryptoInstance, + subtle: linerCryptoInstance.subtle, + name: 'webcrypto-liner', + }); + } +} + +/** Get a PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */ +export function getLinerCryptoEngine(): pkijs.CryptoEngine { + ensureLiner(); + return linerEngine!; +} + +/** + * Run an async operation with the global PKI.js engine set to webcrypto-liner, + * then restore the previous engine afterwards. + * + * Required for operations that use the global engine internally + * (e.g. PFX.parseInternalValues for PKCS#12 import). + */ +export async function withLinerEngine(fn: () => Promise): Promise { + ensureLiner(); + + // Save the current global engine so we can restore it + const prev = pkijs.getEngine(); + + pkijs.setEngine('webcrypto-liner', linerCryptoInstance!, linerEngine!); + try { + return await fn(); + } finally { + // Restore the previous engine + pkijs.setEngine(prev.name, prev.crypto as unknown as pkijs.CryptoEngine); + } +} diff --git a/lib/smime/pkcs12-import.ts b/lib/smime/pkcs12-import.ts index 38968302..324a32d0 100644 --- a/lib/smime/pkcs12-import.ts +++ b/lib/smime/pkcs12-import.ts @@ -6,6 +6,7 @@ import { classifyCapabilities, } from './certificate-utils'; import type { SmimeKeyRecord, Pkcs12ImportResult } from './types'; +import { withLinerEngine } from './crypto-engine'; const KDF_ITERATIONS = 600_000; const AES_KEY_LENGTH = 256; @@ -39,9 +40,12 @@ export async function importPkcs12( // PKIjs handles MAC verification internally during parseInternalValues } - // Parse internal values - await pfx.parseInternalValues({ - password: stringToAB(p12Passphrase), + // Use webcrypto-liner as the global engine for 3DES support. + // Many PKCS#12 files use pbeWithSHAAnd3-KeyTripleDES-CBC internally. + await withLinerEngine(async () => { + await pfx.parseInternalValues({ + password: stringToAB(p12Passphrase), + }); }); // Extract certificates and private key from parsed PKCS#12 @@ -63,7 +67,9 @@ export async function importPkcs12( } return {}; }); - await authSafe.parseInternalValues({ safeContents: safeContentsParams }); + await withLinerEngine(async () => { + await authSafe.parseInternalValues({ safeContents: safeContentsParams }); + }); for (const safeContent of authSafe.parsedValue.safeContents) { const sc = safeContent.value ?? safeContent.parsedValue; @@ -115,8 +121,10 @@ export async function importPkcs12( privateKeyInfo = shroudedBag.parsedValue; } else { // Decrypt shrouded key bag to get private key info - await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise }).parseInternalValues({ - password: stringToAB(p12Passphrase), + await withLinerEngine(async () => { + await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise }).parseInternalValues({ + password: stringToAB(p12Passphrase), + }); }); if (shroudedBag.parsedValue) { privateKeyInfo = shroudedBag.parsedValue; diff --git a/lib/smime/smime-decrypt.ts b/lib/smime/smime-decrypt.ts index 42d613d7..b4e4b17e 100644 --- a/lib/smime/smime-decrypt.ts +++ b/lib/smime/smime-decrypt.ts @@ -8,6 +8,7 @@ import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; import type { SmimeKeyRecord } from './types'; +import { getLinerCryptoEngine } from './crypto-engine'; export interface DecryptionInput { /** Raw CMS EnvelopedData bytes (DER) */ @@ -359,11 +360,8 @@ async function decryptWithKey( const certAsn1 = asn1js.fromBER(keyRecord.certificate); const cert = new pkijs.Certificate({ schema: certAsn1.result }); - const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - }); + // Use webcrypto-liner engine for legacy algorithm support (e.g. 3DES) + const cryptoEngine = getLinerCryptoEngine(); const result = await envelopedData.decrypt( recipientIndex, diff --git a/package-lock.json b/package-lock.json index 29a0cbbe..ae68c527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^19.2.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "webcrypto-liner": "^1.4.3", "zustand": "^5.0.9" }, "devDependencies": { @@ -2375,6 +2376,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -2761,6 +2785,44 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^1.0.1" + } + }, + "node_modules/@stablelib/hash": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz", + "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==", + "license": "MIT" + }, + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" + }, + "node_modules/@stablelib/sha3": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz", + "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^1.0.1", + "@stablelib/hash": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4075,6 +4137,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asmcrypto.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", + "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", + "license": "MIT" + }, "node_modules/asn1js": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", @@ -4154,6 +4222,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -4177,6 +4251,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4376,6 +4456,17 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4600,6 +4691,16 @@ "node": ">=6" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4660,6 +4761,20 @@ "dev": true, "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.5.0", + "resolved": "git+ssh://git@github.com/mahrud/elliptic.git#75637c76678e83c31682fd967c2fa9ff4761b3fc", + "license": "MIT", + "dependencies": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5663,6 +5778,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5693,6 +5818,17 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5812,6 +5948,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6779,6 +6921,18 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -8805,6 +8959,38 @@ "node": ">=18" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, + "node_modules/webcrypto-liner": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webcrypto-liner/-/webcrypto-liner-1.4.3.tgz", + "integrity": "sha512-gzlk7ciS5zqc8QZMwpzpRxxwkcQKDJDndhr/hHWQe18Rzafhji3a7CaSxIeA2jcL0bLcAK+P77K3lWS1QXMMYA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "@stablelib/sha3": "^1.0.1", + "asmcrypto.js": "^2.3.2", + "asn1js": "^3.0.5", + "core-js": "^3.35.1", + "des.js": "^1.1.0", + "elliptic": "git+https://github.com/mahrud/elliptic.git", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.7.8" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index c80f97ae..fe43fab4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-dom": "^19.2.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "webcrypto-liner": "^1.4.3", "zustand": "^5.0.9" }, "devDependencies": { From 19584cfef69d17b55f771e2263d27bdb2c93a785 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 14:44:12 +0100 Subject: [PATCH 2/8] feat: add email import/export functionality with .eml support --- components/email/email-viewer.tsx | 90 +++++++++++++++++++++++++++++++ locales/en/common.json | 7 ++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 563291be..0193011f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -61,6 +61,7 @@ import { Inbox, Folder, Sun, + Upload, Moon, HelpCircle, } from "lucide-react"; @@ -72,6 +73,7 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from " import { toast } from "@/stores/toast-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; @@ -2221,6 +2223,45 @@ export function EmailViewer({ } }, []); + // Export email as .eml file + const handleExportEmail = async () => { + if (!email?.blobId || !client) return; + try { + const subject = (email.subject || 'email').replace(/[<>:"/\\|?*]+/g, '_').slice(0, 100); + await client.downloadBlob(email.blobId, `${subject}.eml`, 'message/rfc822'); + } catch { + toast.error(tNotifications('export_email_error')); + } + }; + + // Import email from .eml file + const handleImportEmail = () => { + if (!client) return; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.eml,message/rfc822'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const { selectedMailbox, mailboxes, fetchEmails } = useEmailStore.getState(); + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const mailboxId = mailbox?.originalId || selectedMailbox; + if (!mailboxId) { + toast.error(tNotifications('import_email_error')); + return; + } + const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' }); + await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true }); + toast.success(tNotifications('import_email_success')); + await fetchEmails(client); + } catch { + toast.error(tNotifications('import_email_error')); + } + }; + input.click(); + }; + // Print only the email content in a new window const handlePrint = () => { if (!email) return; @@ -2483,6 +2524,21 @@ export function EmailViewer({ {t('view_source')} +
+ + {onShowShortcuts && ( +
+ {/* Export email */} + + {/* Import email */} + {onShowShortcuts && ( +
+ {/* Export email */} + + {/* Import email */} + {onShowShortcuts && ( + )} + + + +
+ + {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */} +
+ {isLoading && ( +
+ +
+ )} + {/* Archive */} + + {/* Delete */} + + {/* Move to folder */} + {moveTree.length > 0 && onMoveToMailbox && ( +
+ + {moveMenuOpen && ( +
+ {(() => { + const renderNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderNodes(moveTree); + })()} +
+ )} +
+ )} + {/* Star/Flag toggle */} + + + {/* Tag Picker — hidden on mobile, overflows to More menu */} +
+
+
+ + {tagMenuOpen && ( +
+ {colorOptions.map((option) => ( + + ))} + {currentColor && ( + <> +
+ + + )} +
+ )} +
+
+ + {/* Spam — hidden on mobile, overflows to More menu */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + + {/* Toggle read state — hidden on mobile, overflows to More menu */} + + + {/* Print — hidden on mobile, overflows to More menu */} + + + {/* View source — hidden on mobile, overflows to More menu */} + + + {/* More menu — click-based */} +
+ + {moreMenuOpen && !isMobile && ( +
+ {/* Overflow: reply */} + + {/* Overflow: reply all */} + + {/* Overflow: forward */} + + {/* Overflow: archive */} + + {/* Overflow: move to folder */} + {moveTree.length > 0 && onMoveToMailbox && ( +
= 6 ? "" : "sm:hidden")}> +
+
{t('move_to')}
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+
+ )} + {/* Overflow: tag submenu */} + {colorOptions.length > 0 && ( +
= 5 ? "" : "sm:hidden")}> +
+
{t('tag')}
+ {colorOptions.map((option) => ( + + ))} + {currentColor && ( + + )} +
+
+ )} + {/* Overflow: spam */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + {/* Overflow: toggle read */} + + {/* Overflow: print */} + + {/* Overflow: view source */} + +
+ {/* Export email */} + + {/* Import email */} + + {onShowShortcuts && ( + + )} +
+ )} +
+
+ + ); + return (
-
- {/* Left: Back + Reply actions */} -
- {isTablet && !tabletListVisible && onBack && ( - - )} - - - -
- - {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print */} -
- {isLoading && ( -
- -
- )} - {/* Archive */} - - {/* Delete */} - - {/* Move to folder — promoted to Button, same design as archive/delete */} - {moveTree.length > 0 && onMoveToMailbox && ( -
- - {moveMenuOpen && ( -
- {(() => { - const renderNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderNodes(moveTree); - })()} -
- )} -
- )} - {/* Star/Flag toggle */} - - - {/* Tag Picker — hidden on mobile, overflows to More menu */} -
-
-
- - {tagMenuOpen && ( -
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( - <> -
- - - )} -
- )} -
-
- - {/* Spam — hidden on mobile, overflows to More menu */} - {(onMarkAsSpam || onUndoSpam) && ( - - )} - - {/* Toggle read state — hidden on mobile, overflows to More menu */} - - - {/* Print — hidden on mobile, overflows to More menu */} - - - {/* View source — hidden on mobile, overflows to More menu */} - - - {/* More menu — click-based */} -
- - {moreMenuOpen && !isMobile && ( -
- {/* Overflow: archive */} - - {/* Overflow: move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( -
= 6 ? "" : "sm:hidden")}> -
-
{t('move_to')}
- {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} -
-
- )} - {/* Overflow: tag submenu */} - {colorOptions.length > 0 && ( -
= 5 ? "" : "sm:hidden")}> -
-
{t('tag')}
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( - - )} -
-
- )} - {/* Overflow: spam */} - {(onMarkAsSpam || onUndoSpam) && ( - - )} - {/* Overflow: toggle read */} - - {/* Overflow: print */} - - {/* Overflow: view source */} - -
- {/* Export email */} - - {/* Import email */} - - {onShowShortcuts && ( - - )} -
- )} -
-
+
+ {renderToolbarItems(true)}
@@ -3118,422 +3161,8 @@ export function EmailViewer({ {toolbarPosition === 'below-subject' && (
-
- {/* Left: Reply actions */} -
- - - -
- - {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */} -
- {isLoading && ( -
- -
- )} - {/* Archive */} - - {/* Delete */} - - {/* Move to folder — promoted to Button, same design as archive/delete */} - {moveTree.length > 0 && onMoveToMailbox && ( -
- - {moveMenuOpen && ( -
- {(() => { - const renderNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderNodes(moveTree); - })()} -
- )} -
- )} - {/* Star/Flag toggle */} - - - {/* Tag Picker — hidden on mobile, overflows to More menu */} -
-
-
- - {tagMenuOpen && ( -
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( - <> -
- - - )} -
- )} -
-
- - {/* Spam — hidden on mobile, overflows to More menu */} - {(onMarkAsSpam || onUndoSpam) && ( - - )} - - {/* Toggle read state — hidden on mobile, overflows to More menu */} - - - {/* Print — hidden on mobile, overflows to More menu */} - - - {/* View source — hidden on mobile, overflows to More menu */} - - - {/* More menu — click-based */} -
- - {moreMenuOpen && !isMobile && ( -
- {/* Overflow: archive */} - - {/* Overflow: move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( -
= 6 ? "" : "sm:hidden")}> -
-
{t('move_to')}
- {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} -
-
- )} - {/* Overflow: tag submenu */} - {colorOptions.length > 0 && ( -
= 5 ? "" : "sm:hidden")}> -
-
{t('tag')}
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( - - )} -
-
- )} - {/* Overflow: spam */} - {(onMarkAsSpam || onUndoSpam) && ( - - )} - {/* Overflow: toggle read */} - - {/* Overflow: print */} - - {/* Overflow: view source */} - -
- {/* Export email */} - - {/* Import email */} - - {onShowShortcuts && ( - - )} -
- )} -
-
+
+ {renderToolbarItems(false)}
From ada356e4403d72f51efe6f20e991a9dec0e08140 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 15:35:17 +0100 Subject: [PATCH 6/8] fix: menu overvlow fixed with submenu support for move and tag actions --- components/email/email-viewer.tsx | 209 +++++++++++++++++------------- 1 file changed, 120 insertions(+), 89 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d711a663..f5121574 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -847,6 +847,7 @@ export function EmailViewer({ const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); + const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null); const [tagMenuOpen, setTagMenuOpen] = useState(false); const [moveMenuOpen, setMoveMenuOpen] = useState(false); const moreMenuRef = useRef(null); @@ -922,6 +923,7 @@ export function EmailViewer({ function handleClickOutside(e: MouseEvent) { if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) { setMoreMenuOpen(false); + setMoreMenuSub(null); } if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) { setTagMenuOpen(false); @@ -961,6 +963,7 @@ export function EmailViewer({ // Temporarily prevent flex shrinking so we can measure natural widths leftGroup.style.flexShrink = '0'; rightGroup.style.flexShrink = '0'; + el.style.overflow = 'hidden'; // Iteratively hide items until content fits let count = 0; const isOverflowing = () => @@ -972,9 +975,10 @@ export function EmailViewer({ item.style.display = 'none'; count++; } - // Restore flex shrinking + // Restore layout leftGroup.style.flexShrink = ''; rightGroup.style.flexShrink = ''; + el.style.overflow = ''; setOverflowCount(prev => prev === count ? prev : count); }; const observer = new ResizeObserver(calculate); @@ -2722,121 +2726,148 @@ export function EmailViewer({ size="sm" className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-8 sm:gap-0 sm:py-0 sm:px-0" title={t('more_actions')} - onClick={() => { setMoreMenuOpen(!moreMenuOpen); setTagMenuOpen(false); setMoveMenuOpen(false); }} + onClick={() => { setMoreMenuOpen(!moreMenuOpen); setMoreMenuSub(null); setTagMenuOpen(false); setMoveMenuOpen(false); }} > {t('more_actions')} {moreMenuOpen && !isMobile && ( -
+
{/* Overflow: reply */} {/* Overflow: reply all */} {/* Overflow: forward */} {/* Overflow: archive */} - {/* Overflow: move to folder */} + {/* Overflow: move to folder — submenu */} {moveTree.length > 0 && onMoveToMailbox && ( -
= 6 ? "" : "sm:hidden")}> -
-
{t('move_to')}
- {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} +
= 6 ? "" : "sm:hidden")} + onMouseEnter={() => setMoreMenuSub('move')} + onMouseLeave={() => setMoreMenuSub(null)} + > + + {moreMenuSub === 'move' && ( +
+ {(() => { + const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { + return nodes.map((node) => { + const Icon = getMoveMailboxIcon(node.role); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {node.name} +
+ )} + {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} -
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+ )}
)} - {/* Overflow: tag submenu */} + {/* Overflow: tag — submenu */} {colorOptions.length > 0 && ( -
= 5 ? "" : "sm:hidden")}> -
-
{t('tag')}
- {colorOptions.map((option) => ( - + {moreMenuSub === 'tag' && ( +
+ {colorOptions.map((option) => ( + + ))} + {currentColor && ( + <> +
+ + )} - > - - {option.name} - {currentColor === option.value && } - - ))} - {currentColor && ( - +
)} -
)} {/* Overflow: spam */} {(onMarkAsSpam || onUndoSpam) && ( {/* Overflow: print */} {/* Overflow: view source */} {/* Import email */} {onShowShortcuts && (