From 460343d86d65a70c40cf06b2495db975bad8a7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20B=C3=A9ti?= Date: Mon, 26 Oct 2026 18:34:57 +0100 Subject: [PATCH] fix: show inline images whose part name is a path #1371 --- components/email/email-viewer.tsx | 5 +++-- lib/__tests__/jmap-client-resilience.test.ts | 9 +++++++++ lib/jmap/__tests__/download-name.test.ts | 17 +++++++++++++++++ lib/jmap/client.ts | 3 ++- lib/jmap/download-name.ts | 14 ++++++++++++++ lib/wopi/files.ts | 3 ++- 6 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 lib/jmap/__tests__/download-name.test.ts create mode 100644 lib/jmap/download-name.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cc179c90f..dec79331b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2307,8 +2307,9 @@ export function EmailViewer({ } else { URL.revokeObjectURL(objectUrl); } - } catch { - // Failed to fetch inline image, will show placeholder + } catch (err) { + // The image shows as the placeholder; say why, or the failure is invisible. + console.warn(`Failed to fetch inline image cid:${cidValue}:`, err); } })); if (!cancelled) { diff --git a/lib/__tests__/jmap-client-resilience.test.ts b/lib/__tests__/jmap-client-resilience.test.ts index 8f86a16a6..f795e73a2 100644 --- a/lib/__tests__/jmap-client-resilience.test.ts +++ b/lib/__tests__/jmap-client-resilience.test.ts @@ -471,6 +471,15 @@ describe('JMAPClient resilience', () => { ); }); + it('keeps only the last path component of a name, so no %2F reaches the proxy (#1371)', async () => { + const client = await connectWith({ + downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?accept={type}', + }); + expect(client.getBlobDownloadUrl('blob1', '/images/banner.jpg', 'image/jpeg', 'acct-1')).toBe( + 'https://mail.example.com/download/acct-1/blob1/banner.jpg?accept=image%2Fjpeg', + ); + }); + it('leaves an empty downloadUrl falsy so the unavailable guard still fires', async () => { const client = await connectWith({ downloadUrl: '' }); expect(() => client.getBlobDownloadUrl('blob1')).toThrow('Download URL not available'); diff --git a/lib/jmap/__tests__/download-name.test.ts b/lib/jmap/__tests__/download-name.test.ts new file mode 100644 index 000000000..a838a4ac5 --- /dev/null +++ b/lib/jmap/__tests__/download-name.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { downloadNameSegment } from '../download-name'; + +describe('downloadNameSegment', () => { + it.each([ + ['banner.jpg', 'banner.jpg'], + ['/images/banner.jpg', 'banner.jpg'], + ['C:\\Users\\me\\report.pdf', 'report.pdf'], + ['100% done.txt', '100% done.txt'], + ])('turns %j into %j', (name, expected) => { + expect(downloadNameSegment(name)).toBe(expected); + }); + + it.each([undefined, '', ' ', '/images/', '\\'])('falls back to "download" for %j', (name) => { + expect(downloadNameSegment(name)).toBe('download'); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 8c290f7bd..3766c32b7 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -33,6 +33,7 @@ import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type Ke import { emailMatchesLogin, sortIdentities } from "@/lib/identity-order"; import type { AccountInfo } from "./types"; import { stalwartChangeId } from "./stalwart-state"; +import { downloadNameSegment } from "./download-name"; import { toInertBlob } from "@/lib/file-preview"; import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; @@ -5826,7 +5827,7 @@ export class JMAPClient implements IJMAPClient { return this.downloadUrl .replace('{accountId}', encodeURIComponent(accountId || this.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) - .replace('{name}', encodeURIComponent(name || 'download')) + .replace('{name}', encodeURIComponent(downloadNameSegment(name))) .replace('{type}', encodeURIComponent(type || 'application/octet-stream')); } diff --git a/lib/jmap/download-name.ts b/lib/jmap/download-name.ts new file mode 100644 index 000000000..3193d0ffb --- /dev/null +++ b/lib/jmap/download-name.ts @@ -0,0 +1,14 @@ +/** + * The {name} segment of a JMAP download URL (RFC 8620 ยง6.2). + * + * The server only uses it for the Content-Disposition filename, but mailers + * sometimes put a path in a part's name ("/images/banner.jpg"). Percent- + * encoded, its slashes become %2F, which Apache (without AllowEncodedSlashes + * NoDecode), Traefik and nginx proxy_pass with a URI reject or decode, so the + * download failed and inline cid: images showed as blank (#1371). Keep the + * last path component instead. + */ +export function downloadNameSegment(name: string | undefined): string { + const base = (name ?? '').split(/[/\\]/).pop()?.trim(); + return base || 'download'; +} diff --git a/lib/wopi/files.ts b/lib/wopi/files.ts index 8fba4fd54..3475d4264 100644 --- a/lib/wopi/files.ts +++ b/lib/wopi/files.ts @@ -2,6 +2,7 @@ import { postJmap, fetchJmapSession, rebaseApiUrl, type JmapSessionDocument } fr import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { jmapError, type JmapErrorInfo } from '@/lib/jmap/jmap-error'; import type { FileNode } from '@/lib/jmap/types'; +import { downloadNameSegment } from '@/lib/jmap/download-name'; /** * Server-side JMAP FileNode content I/O for the WOPI host (#425). @@ -148,7 +149,7 @@ export async function downloadFileBlob( const url = expandTemplate(downloadUrl, { accountId, blobId, - name, + name: downloadNameSegment(name), type: type || 'application/octet-stream', }); return fetchJmapServer(url, {