fix: show inline images whose part name is a path #1371

This commit is contained in:
Julien Béti
2026-10-26 18:34:57 +01:00
parent d76a472022
commit 460343d86d
6 changed files with 47 additions and 4 deletions
+3 -2
View File
@@ -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) {
@@ -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');
+17
View File
@@ -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');
});
});
+2 -1
View File
@@ -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'));
}
+14
View File
@@ -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';
}
+2 -1
View File
@@ -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, {