diff --git a/.env.example b/.env.example index c2f2b40c..99da0cb0 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OpenID Connect issuer URL for discovery # OAUTH_ISSUER_URL=https://your-idp.example.com +# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses. +# Off by default as an SSRF guard. Enable for split-DNS deployments where the +# OAuth issuer's public hostname resolves to an internal IP from this server. +# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true + # ============================================================================= # Session & Security # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 358dd5fd..f233e76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.7.1 (2026-05-22) + +### Features + +- **Admin**: Expose PWA branding fields in the admin Branding tab +- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space + +### Fixes + +- **Mail**: Preserve inline images when replying (#163) +- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313) +- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop + ## 1.7.0 (2026-05-21) > **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced. diff --git a/README.md b/README.md index acd19eab..4abe0f3d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) diff --git a/VERSION b/VERSION index bd8bf882..943f9cbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 +1.7.1 diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index a469c194..a0845972 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; -import { getPathPrefix } from "@/lib/browser-navigation"; +import { apiFetch, getPathPrefix } from "@/lib/browser-navigation"; import { Loader2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; @@ -96,7 +96,7 @@ function OAuthCallbackInner() { // the refresh-token cookie write for the same reason. (async () => { try { - const res = await fetch("/api/auth/sso/complete", { + const res = await apiFetch("/api/auth/sso/complete", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 94532082..e9c283ef 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -57,6 +57,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; +import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { resolveReplyFrom } from "@/lib/reply-identity"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -1936,17 +1937,24 @@ export default function Home() { const input = document.createElement('input'); input.type = 'file'; - input.accept = '.eml,message/rfc822'; + input.accept = EML_IMPORT_ACCEPT; input.multiple = true; input.onchange = async (e) => { const files = Array.from((e.target as HTMLInputElement).files ?? []); if (files.length === 0) return; + let emails; + try { + emails = await expandImportableEmails(files); + } catch { + toast.error(t('notifications.import_email_error')); + return; + } + let imported = 0; let failed = 0; - for (const file of files) { + for (const { blob } of emails) { try { - const blob = new Blob([await file.arrayBuffer()], { type: 'message/rfc822' }); await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }); imported++; } catch { @@ -1958,7 +1966,7 @@ export default function Home() { toast.success(t('notifications.import_email_success')); if (selectedMailbox) await fetchEmails(client, selectedMailbox); } - if (failed > 0) { + if (failed > 0 || (imported === 0 && emails.length === 0)) { toast.error(t('notifications.import_email_error')); } }; diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index c8b00f14..1d1005f0 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -16,6 +16,7 @@ import { PaneSizeContext } from "@/hooks/use-pane-size"; import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; import { cn } from "@/lib/utils"; +import { getPathPrefix } from "@/lib/browser-navigation"; import MailPage from "@/app/(main)/[locale]/page"; import CalendarPage from "@/app/(main)/[locale]/calendar/page"; @@ -172,7 +173,7 @@ export default function ProHome() { // enabled it. If either precondition stops holding, hand the user back // to the standard shell. if (isMobile || isTablet || !proInterface) { - window.location.replace("/"); + window.location.replace(`${getPathPrefix()}/`); } }, [initialCheckDone, isMobile, isTablet, proInterface]); diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 9d129369..ab7dcad7 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -33,6 +33,7 @@ import { Languages, Info, Bug, + Download, X, type LucideIcon, } from 'lucide-react'; @@ -59,6 +60,7 @@ import { FolderSettings } from '@/components/settings/folder-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings'; +import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; @@ -90,6 +92,7 @@ type Tab = | 'layout' | 'reading' | 'composing' + | 'downloads' | 'identities' | 'vacation' | 'filters' @@ -126,6 +129,7 @@ const tabIcons: Record = { layout: LayoutGrid, reading: BookOpen, composing: PenLine, + downloads: Download, identities: UserPen, vacation: PalmtreeIcon, filters: Filter, @@ -202,6 +206,7 @@ const tabSearchPaths: Record = { 'settings.email_behavior.signature_position', 'settings.email_behavior.sub_address_delimiter', ], + downloads: ['settings.downloads'], identities: ['settings.identities'], vacation: ['settings.vacation'], filters: ['settings.filters'], @@ -236,6 +241,7 @@ const tabKeywords: Record = { layout: 'toolbar sidebar account switcher unified mailbox icons rail', reading: 'mark read preview thread conversation archive delete attachment open', composing: 'editor signature plain text reply forward draft compose', + downloads: 'download filename template eml attachment save export', identities: 'from address signature email', vacation: 'auto reply away out of office holiday responder', filters: 'sieve rules block junk forward', @@ -580,6 +586,7 @@ export default function SettingsPage() { // Mail { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, + { id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' }, { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), @@ -666,6 +673,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'layout' && } {effectiveActiveTab === 'reading' && } {effectiveActiveTab === 'composing' && } + {effectiveActiveTab === 'downloads' && } {effectiveActiveTab === 'identities' && } {effectiveActiveTab === 'vacation' && } {effectiveActiveTab === 'filters' && } diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx index 4387ef2e..5bda49b3 100644 --- a/app/(main)/admin/_tabs/auth.tsx +++ b/app/(main)/admin/_tabs/auth.tsx @@ -273,6 +273,7 @@ export function AuthTab() { + diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 1af87b9e..78755819 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [ { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, - { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, + { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] }, { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 64e9cad6..686bb202 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -258,7 +258,7 @@ export default function SetupWizardPage() { // edge cases that swallow client-side replaces after the // setupComplete flag flips. setTimeout(() => { - window.location.assign('/admin/login'); + window.location.assign(`${getPathPrefix()}/admin/login`); }, 1500); }} /> @@ -340,13 +340,13 @@ function CompletedScreen() {
Sign in to admin dashboard Open webmail login diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index fd0e861f..9edfbd31 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -3,9 +3,8 @@ import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptPayload } from '@/lib/auth/crypto'; import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; -import { getRequiredConfig } from '@/lib/oauth/token-exchange'; +import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { discoverOAuth } from '@/lib/oauth/discovery'; -import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { getOauthScopes } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { hasSessionSecret } from '@/lib/auth/session-secret'; @@ -62,7 +61,7 @@ export async function POST(request: NextRequest) { } const { clientId, discoveryUrl } = getRequiredConfig(serverId); - const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl }); + const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() }); if (!metadata?.authorization_endpoint) { return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index e19bc781..11fa1a6b 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { discoverOAuth } from '@/lib/oauth/discovery'; +import { getDiscoveryValidator } from '@/lib/oauth/token-exchange'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; @@ -52,9 +53,13 @@ async function tryTokenRequest( } } -async function findTokenEndpoint(serverUrl: string): Promise { +async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise { + // Admin-trusted callers (matched server entry or configured JMAP server URL) + // honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always + // go through the SSRF validator regardless of the setting. + const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl; // 1. Try OAuth discovery - const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl }); + const metadata = await discoverOAuth(serverUrl, { validateEndpoint }); if (metadata?.token_endpoint) return metadata.token_endpoint; // 2. Try common Stalwart token endpoint paths directly @@ -105,14 +110,17 @@ export async function POST(request: NextRequest) { let upstreamUrl: string; let resolvedServerId: string | null = null; + let adminTrusted = false; const requestedEntry = findServerById(serverList, requestedServerId); const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl); if (matchedEntry) { upstreamUrl = matchedEntry.url; resolvedServerId = matchedEntry.id; + adminTrusted = true; } else if (configuredServerUrl) { upstreamUrl = configuredServerUrl; + adminTrusted = true; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { logger.warn('TOTP token exchange: rejected non-public server URL'); @@ -123,7 +131,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 }); } - const tokenEndpoint = await findTokenEndpoint(upstreamUrl); + const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted); if (!tokenEndpoint) { logger.warn('TOTP token exchange: no token endpoint found'); return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); diff --git a/app/globals.css b/app/globals.css index 70e5b862..55ef6a77 100644 --- a/app/globals.css +++ b/app/globals.css @@ -242,8 +242,8 @@ body { @media (max-width: 640px) { .email-content-text { - padding-left: 0; - padding-right: 0; + padding-left: 0.75rem; + padding-right: 0.75rem; } } diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 902a6e5c..f797f0ea 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -78,7 +78,11 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const calendarName = calendar?.name || ""; const durationMinutes = parseDuration(event.duration); const endTime = getEventEndDate(event); - const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`; + const safeFormat = (d: Date, fmt: string) => { + if (isNaN(d.getTime())) return "--:--"; + try { return format(d, fmt); } catch { return "--:--"; } + }; + const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`; const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; const handleDragStart = useCallback((e: DragEvent) => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dc7c280d..d93ab922 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,6 +3,8 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; +import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; +import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { Button } from "@/components/ui/button"; @@ -803,6 +805,7 @@ interface DraggableAttachmentChipProps { attachment: EffectiveAttachment; client: IJMAPClient | null; enabled: boolean; + downloadName?: string; children: (dragProps: { draggable: boolean; onPointerEnter: () => void; @@ -811,9 +814,9 @@ interface DraggableAttachmentChipProps { }) => React.ReactNode; } -function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) { +function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) { const source = useMemo(() => ({ - name: attachment.name || 'download', + name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', getBlobUrl: async () => { if (attachment.blobId && client) { @@ -836,7 +839,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, children }: Drag } return null; }, - }), [attachment, client]); + }), [attachment, client, downloadName]); const drag = useAttachmentDrag(source, enabled); return <>{children(drag)}; } @@ -909,6 +912,26 @@ export function EmailViewer({ const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); + const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE; + const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE; + const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement); + const filenameLowercase = useSettingsStore((state) => state.filenameLowercase); + const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics); + const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators); + const emailFilenameOptions = useMemo(() => ({ + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); + const attachmentFilenameOptions = useMemo(() => ({ + template: attachmentDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; @@ -2594,6 +2617,15 @@ export function EmailViewer({ return emailContent; }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); + const resolveAttachmentName = useCallback( + (attachment: EffectiveAttachment) => { + const fallback = attachment.name || 'download'; + if (!email) return fallback; + return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback; + }, + [email, attachmentFilenameOptions], + ); + const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); // Blob URLs inherit our origin; script-bearing MIME types (text/html, @@ -2603,6 +2635,8 @@ export function EmailViewer({ && mailAttachmentAction === 'preview' && isMimeTypeSafeForInlinePreview(attachment.type); + const downloadName = resolveAttachmentName(attachment); + const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, @@ -2613,7 +2647,7 @@ export function EmailViewer({ if (attachment.blobId && onDownloadAttachment) { emailHooks.onAttachmentDownload.emit(info); - onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); + onDownloadAttachment(attachment.blobId, downloadName, attachment.type); return; } @@ -2633,7 +2667,7 @@ export function EmailViewer({ emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -2663,16 +2697,17 @@ export function EmailViewer({ emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [mailAttachmentAction, onDownloadAttachment, email?.id]); + }, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]); const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { + const downloadName = resolveAttachmentName(attachment); const info: AttachmentInfo = { name: attachment.name || '', type: attachment.type, @@ -2682,7 +2717,7 @@ export function EmailViewer({ }; emailHooks.onAttachmentDownload.emit(info); if (attachment.blobId && onDownloadAttachment) { - onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true); + onDownloadAttachment(attachment.blobId, downloadName, attachment.type, true); return; } @@ -2695,7 +2730,7 @@ export function EmailViewer({ const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); @@ -2711,12 +2746,12 @@ export function EmailViewer({ const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = attachment.name || 'download'; + anchor.download = downloadName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [onDownloadAttachment, email?.id]); + }, [onDownloadAttachment, email?.id, resolveAttachmentName]); // Pre-fetch object URLs for image attachments so their actual contents can be // rendered as thumbnails inside the chip. Skips images larger than 10 MB. @@ -2832,7 +2867,9 @@ export function EmailViewer({ // Word/Outlook HTML emails ship a