From 4d545f8f582ecb28056365b94543227690bc0751 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:06:12 +0100 Subject: [PATCH] fix: show inline images whose part name is a path --- src/api/__tests__/blob.test.ts | 9 +++++++++ src/api/__tests__/jmap-client-hardening.test.ts | 2 ++ src/api/blob.ts | 3 ++- src/api/download-name.ts | 15 +++++++++++++++ src/api/jmap-client.ts | 3 ++- 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/api/download-name.ts diff --git a/src/api/__tests__/blob.test.ts b/src/api/__tests__/blob.test.ts index 7fa818a3..5869c34e 100644 --- a/src/api/__tests__/blob.test.ts +++ b/src/api/__tests__/blob.test.ts @@ -69,6 +69,15 @@ describe('blob operations', () => { expect(url).toContain('my%20file%20(1).pdf'); }); + + // A part named like a path: an encoded slash (%2F) in the URL path is + // refused or decoded by common reverse proxies, so inline images went + // blank (webmail #1371). + it('keeps only the last path component of a part name', () => { + expect(getDownloadUrl('b1', '/images/banner.jpg', 'image/jpeg')).toContain('/b1/banner.jpg?'); + expect(getDownloadUrl('b1', 'C:\\pics\\logo.png', 'image/png')).toContain('/b1/logo.png?'); + expect(getDownloadUrl('b1', 'dir/', 'image/png')).toContain('/b1/download?'); + }); }); }); diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index c8ab6008..a3c732b9 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -194,6 +194,8 @@ describe('JMAPClient hardening', () => { const session = await client.connect('https://mail.example.com', 'user', 'pass'); expect(session.apiUrl).toBe('https://mail.example.com/jmap/'); expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream'); + // A part name that is a path keeps its last component (webmail #1371). + expect(client.getBlobDownloadUrl('b1', '/images/n.jpg')).toBe('https://mail.example.com/download/acc-1/b1/n.jpg?type=application%2Foctet-stream'); }); it('refetches the final URL when a redirect dropped the Authorization header', async () => { diff --git a/src/api/blob.ts b/src/api/blob.ts index 9f87e2d1..2bff5f5f 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -2,6 +2,7 @@ import { File } from 'expo-file-system'; import type * as LegacyFileSystemTypes from 'expo-file-system/legacy'; import { jmapClient } from './jmap-client'; import { batched } from './jmap-result'; +import { downloadNameSegment } from './download-name'; import { CAPABILITIES } from './types'; import { getClientCertAlias, secureFetch } from '../lib/client-cert'; @@ -268,7 +269,7 @@ export function getDownloadUrl( return session.downloadUrl .replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.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/src/api/download-name.ts b/src/api/download-name.ts new file mode 100644 index 00000000..324258fe --- /dev/null +++ b/src/api/download-name.ts @@ -0,0 +1,15 @@ +/** + * The {name} segment of a JMAP download URL (RFC 8620 ยง6.2). Port of the + * webmail's lib/jmap/download-name.ts (#1371). + * + * 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. 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/src/api/jmap-client.ts b/src/api/jmap-client.ts index 55133a7c..e46e12a6 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -15,6 +15,7 @@ import { type OAuthTokenSource, } from '../lib/oauth'; import { FirstTouchGate } from './first-touch-gate'; +import { downloadNameSegment } from './download-name'; import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared'; import { beginOwnWrite, recordOwnEmailWrites } from './own-writes'; @@ -1347,7 +1348,7 @@ export class JMAPClient { return this.session.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')); }