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/CHANGELOG.md b/CHANGELOG.md index 977a4480..f2932b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.4.0 (2026-03-17) +## 1.4.1 (2026-03-18) ### Features diff --git a/README.md b/README.md index 343bbdf1..805df4af 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.4.0-green.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.1-green.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue)](https://ghcr.io/bulwarkmail/webmail) diff --git a/VERSION b/VERSION index 88c5fb89..347f5833 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index c0d4cb99..074e4ec4 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.0"; +const APP_VERSION = "1.4.1"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 563291be..89ed732a 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"; @@ -87,6 +89,7 @@ import { smimeVerify } from "@/lib/smime/smime-verify"; import { useSmimeStore } from "@/stores/smime-store"; import type { SmimeStatus } from "@/lib/smime/types"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; +import { debug } from "@/lib/debug"; import type { TnefAttachment } from "@/lib/tnef"; interface EmailViewerProps { @@ -844,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); @@ -919,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); @@ -955,6 +960,10 @@ export function EmailViewer({ const leftGroup = el.firstElementChild as HTMLElement; const rightGroup = el.lastElementChild as HTMLElement; const mainGap = parseFloat(getComputedStyle(el).gap) || 0; + // 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 = () => @@ -966,6 +975,10 @@ export function EmailViewer({ item.style.display = 'none'; count++; } + // Restore layout + leftGroup.style.flexShrink = ''; + rightGroup.style.flexShrink = ''; + el.style.overflow = ''; setOverflowCount(prev => prev === count ? prev : count); }; const observer = new ResizeObserver(calculate); @@ -1615,26 +1628,59 @@ export function EmailViewer({ if (!email?.attachments || !client) return; const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); - if (!tnefAtt?.blobId) return; + if (!tnefAtt?.blobId) { + debug.log('TNEF: No winmail.dat attachment found in email', email?.id); + return; + } + + debug.group('TNEF Processing'); + debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); // Only process if the email has no usable HTML body const hasHtmlBody = !!( email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim() ); - if (hasHtmlBody) return; + if (hasHtmlBody) { + debug.log('TNEF: Email already has HTML body, skipping TNEF extraction'); + debug.log(' HTML partId:', email.htmlBody?.[0]?.partId, 'body length:', email.bodyValues?.[email.htmlBody![0].partId]?.value?.length); + debug.groupEnd(); + return; + } + debug.log('TNEF: Email has no HTML body, proceeding with TNEF extraction'); let cancelled = false; async function processTnef() { try { + debug.time('TNEF fetch blob'); const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!); - if (cancelled || blobBytes.byteLength === 0) return; + debug.timeEnd('TNEF fetch blob'); + debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes'); + + if (cancelled) { + debug.log('TNEF: Processing cancelled after fetch'); + debug.groupEnd(); + return; + } + if (blobBytes.byteLength === 0) { + debug.warn('TNEF: Fetched blob is empty (0 bytes)'); + debug.groupEnd(); + return; + } const tnefData = new Uint8Array(blobBytes); + debug.time('TNEF parse'); const parsed = parseTnef(tnefData); + debug.timeEnd('TNEF parse'); - if (cancelled) return; + if (cancelled) { + debug.log('TNEF: Processing cancelled after parse'); + debug.groupEnd(); + return; + } + + debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); if (parsed.htmlBody) { setTnefHtml(parsed.htmlBody); @@ -1644,9 +1690,17 @@ export function EmailViewer({ } if (parsed.attachments.length > 0) { setTnefAttachments(parsed.attachments); + debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', ')); } - } catch { - // TNEF parsing failed — fall through to plain text display + + if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) { + debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format'); + } + + debug.groupEnd(); + } catch (err) { + debug.error('TNEF processing failed for email', email?.id, err); + debug.groupEnd(); } } @@ -1923,7 +1977,16 @@ export function EmailViewer({ if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; - useHtmlVersion = !!htmlContent; + // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). + // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. + const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; + if (hasTextBody && htmlContent) { + const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim(); + const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped); + useHtmlVersion = hasRichContent; + } else { + useHtmlVersion = !!htmlContent; + } } // If we should use HTML version and it exists @@ -2188,9 +2251,10 @@ export function EmailViewer({ ${effectiveEmailContent.html}`; @@ -2221,6 +2285,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; @@ -2241,7 +2344,7 @@ export function EmailViewer({ .meta { font-size: 13px; color: #555; line-height: 1.6; } .meta strong { color: #000; } .body { font-size: 14px; line-height: 1.6; } - .body img { max-width: 100%; } + .body img { max-width: 100% !important; height: auto !important; } @media print { body { margin: 20px; } }
@@ -2343,6 +2446,495 @@ export function EmailViewer({ const isUnread = !email.keywords?.$seen; const isImportant = email.keywords?.["$important"]; + // Shared toolbar items used by both 'top' and 'below-subject' positions + const renderToolbarItems = (showBackButton: boolean) => ( + <> + {/* Left: Reply actions */} +
+ {showBackButton && isTablet && !tabletListVisible && onBack && ( + + )} + + + +
+ + {/* 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 — submenu */} + {moveTree.length > 0 && onMoveToMailbox && ( +
= 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)} +
+ ); + }); + }; + return renderMobileNodes(moveTree); + })()} +
+ )} +
+ )} + {/* Overflow: tag — submenu */} + {colorOptions.length > 0 && ( +
= 5 ? "" : "sm:hidden")} + onMouseEnter={() => setMoreMenuSub('tag')} + onMouseLeave={() => setMoreMenuSub(null)} + > + + {moreMenuSub === 'tag' && ( +
+ {colorOptions.map((option) => ( + + ))} + {currentColor && ( + <> +
+ + + )} +
+ )} +
+ )} + {/* Overflow: spam */} + {(onMarkAsSpam || onUndoSpam) && ( + + )} + {/* Overflow: toggle read */} + + {/* Overflow: print */} + + {/* Overflow: view source */} + +
+ {/* Export email */} + + {/* Import email */} + + {onShowShortcuts && ( + + )} +
+ )} +
+
+ + ); + return (
{t('view_source')} +
+ + {onShowShortcuts && ( - )} - - - -
- - {/* 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 */} - - {onShowShortcuts && ( - - )} -
- )} -
-
+ {renderToolbarItems(true)}
@@ -2995,404 +3194,7 @@ export function EmailViewer({
- {/* 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 */} - - {onShowShortcuts && ( - - )} -
- )} -
-
+ {renderToolbarItems(false)}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 572bd793..13aab9ef 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -316,7 +316,16 @@ function EmailCard({ if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; - useHtmlVersion = !!htmlContent; + // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). + // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. + const hasTextBody = email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]; + if (hasTextBody && htmlContent) { + const stripped = htmlContent.replace(/<\/?(html|head|body|meta|!doctype|!DOCTYPE|br\s*\/?)[^>]*>/gi, '').trim(); + const hasRichContent = /<(table|tr|td|th|img|style|link|div\s+[^>]*class|span\s+[^>]*class|font|center|blockquote|ul|ol|li|h[1-6])\b/i.test(stripped); + useHtmlVersion = hasRichContent; + } else { + useHtmlVersion = !!htmlContent; + } } if (useHtmlVersion && htmlContent) { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 02da6f39..afc91070 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1088,7 +1088,16 @@ export class JMAPClient { ["Email/get", { accountId: targetAccountId, ids: thread.emailIds, - properties: [...EMAIL_LIST_PROPERTIES], + properties: [ + ...EMAIL_LIST_PROPERTIES, + "textBody", "htmlBody", "bodyValues", + "attachments", "blobId", "sentAt", "bcc", "replyTo", + "messageId", "inReplyTo", "references", "headers", "bodyStructure", + ], + fetchTextBodyValues: true, + fetchHTMLBodyValues: true, + fetchAllBodyValues: true, + maxBodyValueBytes: 256000, }, "0"], ]); 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/lib/tnef.ts b/lib/tnef.ts index e6a88a75..42200e3f 100644 --- a/lib/tnef.ts +++ b/lib/tnef.ts @@ -7,6 +7,8 @@ * Reference: MS-OXTNEF / MS-TNEF specification. */ +import { debug } from '@/lib/debug'; + // TNEF signature const TNEF_SIGNATURE = 0x223E9F78; @@ -234,35 +236,58 @@ export function parseTnef(data: Uint8Array): TnefResult { attachments: [], }; - if (data.byteLength < 6) return result; + debug.group('TNEF Parser'); + debug.log('Input data size:', data.byteLength, 'bytes'); + + if (data.byteLength < 6) { + debug.warn('TNEF data too small (< 6 bytes), skipping'); + debug.groupEnd(); + return result; + } const r = new BinaryReader(data); const signature = r.readUint32LE(); if (signature !== TNEF_SIGNATURE) { + debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)'); + debug.groupEnd(); return result; } + debug.log('TNEF signature valid'); r.skip(2); // legacy key // Current attachment being assembled let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null; + let attrCount = 0; while (r.remaining >= 11) { const level = r.readUint8(); const attrID = r.readUint32LE(); const attrLen = r.readUint32LE(); + attrCount++; - if (attrLen > r.remaining - 2) break; // not enough data for payload + checksum + if (attrLen > r.remaining - 2) { + debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available'); + break; + } const attrData = r.readBytes(attrLen); r.skip(2); // checksum + const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')'; + debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen); + if (level === LVL_MESSAGE) { if (attrID === attBody) { result.body = new TextDecoder('utf-8').decode(attrData); + debug.log(' → Extracted plain text body (' + result.body.length + ' chars)'); } else if (attrID === attMAPIProps) { const props = parseMAPIProps(attrData); + debug.log(' → Parsed', props.size, 'MAPI properties from message'); + props.forEach((val, propID) => { + debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); + }); // HTML body const htmlProp = props.get(PR_BODY_HTML); @@ -273,6 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult { } else { result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value); } + debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)'); + } else { + debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props'); } // Plain text body from MAPI props (fallback) @@ -280,6 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult { const bodyProp = props.get(PR_BODY); if (bodyProp?.value instanceof Uint8Array) { result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF); + debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)'); + } else { + debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props'); } } } @@ -287,6 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult { if (attrID === attAttachRenddata) { // Start of a new attachment — flush previous if (curAttach?.data) { + debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, @@ -294,28 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult { }); } curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null }; + debug.log(' → New attachment started'); } else if (attrID === attAttachTitle && curAttach) { let len = attrData.byteLength; if (len > 0 && attrData[len - 1] === 0) len--; curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len)); + debug.log(' → Attachment short name:', curAttach.name); } else if (attrID === attAttachData && curAttach) { curAttach.data = attrData; + debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes'); } else if (attrID === attAttachment && curAttach) { const props = parseMAPIProps(attrData); + debug.log(' → Parsed', props.size, 'MAPI properties from attachment'); + props.forEach((val, propID) => { + debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value)); + }); const longName = props.get(PR_ATTACH_LONG_FILENAME); if (longName?.value instanceof Uint8Array) { curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF); + debug.log(' → Attachment long filename:', curAttach.name); } const mimeTag = props.get(PR_ATTACH_MIME_TAG); if (mimeTag?.value instanceof Uint8Array) { curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF); + debug.log(' → Attachment MIME type:', curAttach.mimeType); } const attachData = props.get(PR_ATTACH_DATA_BIN); if (attachData?.value instanceof Uint8Array) { curAttach.data = attachData.value; + debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes'); + } else { + debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props'); } } } @@ -323,6 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult { // Flush last attachment if (curAttach?.data) { + debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, @@ -330,6 +375,12 @@ export function parseTnef(data: Uint8Array): TnefResult { }); } + debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length); + if (result.attachments.length > 0) { + debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength }))); + } + debug.groupEnd(); + return result; } diff --git a/locales/en/common.json b/locales/en/common.json index e3883602..cdede18f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -191,6 +191,8 @@ "mark_read": "Mark as read", "print": "Print", "view_source": "View source", + "export_email": "Export as .eml", + "import_email": "Import .eml", "keyboard_shortcuts": "Keyboard shortcuts (?)", "email_source": "Email Source", "copy_source": "Copy to clipboard", @@ -527,7 +529,10 @@ "templates_exported": "Templates exported successfully", "templates_imported": "{count, plural, one {# template} other {# templates}} imported", "templates_import_errors": "Some templates could not be imported", - "templates_import_empty": "No templates found in the file" + "templates_import_empty": "No templates found in the file", + "export_email_error": "Failed to export email", + "import_email_success": "Email imported successfully", + "import_email_error": "Failed to import email" }, "date": { "today": "Today", diff --git a/package-lock.json b/package-lock.json index 29a0cbbe..7e0e19d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.4.0", + "version": "1.4.1", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.18", @@ -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..b0c1efd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.0", + "version": "1.4.1", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", @@ -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": {