From bed8e522c21a74770288db13d5c1800a12f1287f Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 10 Nov 2026 23:47:12 +0100 Subject: [PATCH 1/4] [Mail] Fix HTML bodies decoded twice when they carry a meta charset --- backend/src/Mail/HtmlRenderer.php | 4 ++++ backend/tests/Mail/HtmlRendererTest.php | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/backend/src/Mail/HtmlRenderer.php b/backend/src/Mail/HtmlRenderer.php index fb68504..ad83f79 100644 --- a/backend/src/Mail/HtmlRenderer.php +++ b/backend/src/Mail/HtmlRenderer.php @@ -43,6 +43,10 @@ static function (array $m) use ($partUrl): string { $html = preg_replace($remote, '', $html) ?? $html; } + // The body is already UTF-8 at this point, but the sanitizer's parser trusts a + // (Outlook loves iso-8859-1) and would decode it a second time. + $html = preg_replace('/]*>/i', '', $html) ?? $html; + return [ 'html' => $this->mail->sanitizeFor('body', $html), 'hasRemoteImages' => $hasRemoteImages, diff --git a/backend/tests/Mail/HtmlRendererTest.php b/backend/tests/Mail/HtmlRendererTest.php index 6fe3ed8..4933a81 100644 --- a/backend/tests/Mail/HtmlRendererTest.php +++ b/backend/tests/Mail/HtmlRendererTest.php @@ -36,6 +36,14 @@ public function testEverythingActiveIsGone(): void self::assertStringContainsString('Overlay', $html); } + public function testMetaCharsetDoesNotReDecodeTheBody(): void + { + $source = '

Envoyé à J’ai

'; + ['html' => $html] = $this->renderer->render($source, static fn () => null, false); + + self::assertStringContainsString('Envoyé à J’ai', $html); + } + public function testRemoteImagesStayWhenAsked(): void { ['html' => $html] = $this->renderer->render('

a

', static fn () => null, true); From 3187d58c99e64f8649cc44b5b2298ec3c92ce229 Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 10 Nov 2026 23:47:12 +0100 Subject: [PATCH 2/4] [Imap] Read RFC 2231 attachment names --- backend/src/Imap/Protocol/BodyStructure.php | 36 +++++++++++++++++-- .../tests/Imap/Protocol/BodyStructureTest.php | 14 ++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/backend/src/Imap/Protocol/BodyStructure.php b/backend/src/Imap/Protocol/BodyStructure.php index e1627a2..14b6dbe 100644 --- a/backend/src/Imap/Protocol/BodyStructure.php +++ b/backend/src/Imap/Protocol/BodyStructure.php @@ -93,7 +93,9 @@ private static function string(array $tokens, int $index): ?string } /** - * ("name" "value" "name" "value") to an associative array. + * ("name" "value" "name" "value") to an associative array. RFC 2231 + * parameters ("filename*", or "filename*0*" "filename*1*" when the server + * did not join the pieces) end up under their plain name. * * @return array */ @@ -103,10 +105,38 @@ private static function pairs(mixed $list): array return []; } $pairs = []; + $extended = []; for ($i = 0; $i + 1 < count($list); $i += 2) { - if (is_string($list[$i]) && is_string($list[$i + 1])) { - $pairs[strtolower($list[$i])] = self::decodeParameter($list[$i + 1]); + if (!is_string($list[$i]) || !is_string($list[$i + 1])) { + continue; } + $name = strtolower($list[$i]); + if (1 === preg_match('/^([^*]+)\*(\d+)?(\*)?$/', $name, $m)) { + $extended[$m[1]][(int) ($m[2] ?? 0)] = ['value' => $list[$i + 1], 'encoded' => '*' === substr($name, -1)]; + } else { + $pairs[$name] = self::decodeParameter($list[$i + 1]); + } + } + + // the extended form wins over the plain one, which is only there for old clients + foreach ($extended as $name => $pieces) { + ksort($pieces); + $charset = 'UTF-8'; + $value = ''; + foreach ($pieces as $index => $piece) { + $text = $piece['value']; + if ($piece['encoded']) { + // only the first piece carries charset'language' + if (0 === $index && 1 === preg_match("/^([^']*)'[^']*'(.*)$/s", $text, $m)) { + $charset = '' === $m[1] ? $charset : $m[1]; + $text = $m[2]; + } + $text = rawurldecode($text); + } + $value .= $text; + } + $converted = 'UTF-8' === strtoupper($charset) ? $value : @iconv($charset, 'UTF-8//IGNORE', $value); + $pairs[$name] = is_string($converted) && mb_check_encoding($converted, 'UTF-8') ? $converted : mb_scrub($value, 'UTF-8'); } return $pairs; diff --git a/backend/tests/Imap/Protocol/BodyStructureTest.php b/backend/tests/Imap/Protocol/BodyStructureTest.php index 323a271..ed06dad 100644 --- a/backend/tests/Imap/Protocol/BodyStructureTest.php +++ b/backend/tests/Imap/Protocol/BodyStructureTest.php @@ -81,4 +81,18 @@ public function testEncodedFilenameIsDecoded(): void self::assertSame('résumé.bin', $part->filename()); } + + public function testRfc2231FilenameIsDecoded(): void + { + $part = self::parse('("application" "pdf" NIL NIL NIL "base64" 10 NIL ("attachment" ("filename*" "utf-8\'\'Compte-rendu%20de%20la%20r%C3%A9union.pdf")) NIL NIL)'); + + self::assertSame('Compte-rendu de la réunion.pdf', $part->filename()); + } + + public function testRfc2231ContinuationsAreJoined(): void + { + $part = self::parse('("application" "pdf" NIL NIL NIL "base64" 10 NIL ("attachment" ("filename*1*" "%E9union" "filename*0*" "iso-8859-1\'fr\'Compte-rendu%20r" "filename*2" ".pdf" "filename" "fallback.pdf")) NIL NIL)'); + + self::assertSame('Compte-rendu réunion.pdf', $part->filename()); + } } From ef73afd20f08c6ebaffe3e8b335962e996490b56 Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 10 Nov 2026 23:47:12 +0100 Subject: [PATCH 3/4] [Attachments] Type icons, full names, inline PDF preview, image thumbnails --- Dockerfile | 7 +- backend/composer.json | 1 + backend/composer.lock | 3 +- backend/src/Controller/MessagesController.php | 57 ++++++- backend/tests/Integration/MessagesTest.php | 6 + frontend/src/MailApp.tsx | 2 +- .../src/components/AttachmentList.module.css | 19 +++ frontend/src/components/AttachmentList.tsx | 143 +++++++++++++++--- frontend/src/components/Reader.tsx | 2 +- frontend/src/main.tsx | 6 +- 10 files changed, 210 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index 39fec5a..6e83b84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,10 @@ FROM caddy:2.10.2 AS caddy FROM php:8.4.27-fpm-alpine LABEL org.opencontainers.image.source="https://github.com/tchapi/postel" -RUN apk add --no-cache supervisor su-exec icu-libs tzdata \ - && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \ - && docker-php-ext-install -j"$(nproc)" intl opcache \ +RUN apk add --no-cache supervisor su-exec icu-libs tzdata libpng libjpeg-turbo libwebp \ + && apk add --no-cache --virtual .build-deps icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev $PHPIZE_DEPS \ + && docker-php-ext-configure gd --with-jpeg --with-webp \ + && docker-php-ext-install -j"$(nproc)" intl opcache gd \ && apk del .build-deps COPY --from=caddy /usr/bin/caddy /usr/bin/caddy diff --git a/backend/composer.json b/backend/composer.json index 66802cc..99bc94e 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "require": { "php": ">=8.4", "ext-ctype": "*", + "ext-gd": "*", "ext-iconv": "*", "doctrine/doctrine-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^4.0", diff --git a/backend/composer.lock b/backend/composer.lock index 4ae9032..046f565 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bda4ab4b031c78c9e77804abc6c31a80", + "content-hash": "792b8b84ee24f283511140f76856c4af", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -13339,6 +13339,7 @@ "platform": { "php": ">=8.4", "ext-ctype": "*", + "ext-gd": "*", "ext-iconv": "*" }, "platform-dev": {}, diff --git a/backend/src/Controller/MessagesController.php b/backend/src/Controller/MessagesController.php index a816761..13df103 100644 --- a/backend/src/Controller/MessagesController.php +++ b/backend/src/Controller/MessagesController.php @@ -16,15 +16,20 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Serializer\SerializerInterface; +use function function_exists; use function sprintf; #[Route('/api/folders/{folder}/messages', requirements: ['folder' => '[^/]+'])] final class MessagesController { + /** Longest side in pixels: the chip shows 28px, this leaves room for 3x screens. */ + private const int THUMBNAIL_SIZE = 96; + public function __construct( private readonly MessageListService $list, private readonly MessageService $messages, @@ -96,20 +101,30 @@ public function raw(#[CurrentUser] RemoteUser $user, string $folder, int $uid, R } /** - * Served as a download, never rendered in place: a part is untrusted bytes. + * Served as a download unless ?inline=1 asks for an image or a PDF, the + * two kinds a browser can show without running anything from the part. */ #[Route('/{uid}/parts/{section}', methods: ['GET'], requirements: ['uid' => '\d+', 'section' => '\d+(\.\d+)*'])] public function part(#[CurrentUser] RemoteUser $user, string $folder, int $uid, string $section, Request $request): Response { $part = $this->messages->part($user, $folder, $uid, $section); - $inline = $request->query->getBoolean('inline') && str_starts_with($part->mime, 'image/'); + if ($request->query->getBoolean('thumb')) { + return $this->thumbnail($part->content); + } + + // plenty of clients attach PDFs as application/octet-stream + $pdf = str_starts_with($part->content, '%PDF-'); + $inline = $request->query->getBoolean('inline') && ($pdf || str_starts_with($part->mime, 'image/')); $response = new Response($part->content, Response::HTTP_OK, [ - 'Content-Type' => $inline ? $part->mime : 'application/octet-stream', + 'Content-Type' => $inline ? ($pdf ? 'application/pdf' : $part->mime) : 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff', - 'Content-Security-Policy' => "default-src 'none'; sandbox", 'Cache-Control' => 'private, max-age=3600', ]); + if (!$inline || !$pdf) { + // NOTE: browsers refuse to start their PDF viewer in a sandboxed document + $response->headers->set('Content-Security-Policy', "default-src 'none'; sandbox"); + } $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition( $inline ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT, $part->filename ?? 'part-'.$section, @@ -119,6 +134,40 @@ public function part(#[CurrentUser] RemoteUser $user, string $folder, int $uid, return $response; } + /** + * A small JPEG of an image part, 404 for anything GD cannot or should not open. + */ + private function thumbnail(string $bytes): Response + { + $size = function_exists('imagecreatefromstring') ? @getimagesizefromstring($bytes) : false; + // decoding takes ~5 bytes per pixel, so no more than 24 MP under our 256M memory_limit + if (false === $size || $size[0] * $size[1] > 24_000_000) { + throw new NotFoundHttpException('No thumbnail'); + } + $image = @imagecreatefromstring($bytes); + if (false === $image) { + throw new NotFoundHttpException('No thumbnail'); + } + + $scale = min(1, self::THUMBNAIL_SIZE / max($size[0], $size[1])); + $width = max(1, (int) round($size[0] * $scale)); + $height = max(1, (int) round($size[1] * $scale)); + $thumb = imagecreatetruecolor($width, $height); + // transparent PNGs would come out black otherwise + imagefill($thumb, 0, 0, (int) imagecolorallocate($thumb, 255, 255, 255)); + imagecopyresampled($thumb, $image, 0, 0, 0, 0, $width, $height, $size[0], $size[1]); + + ob_start(); + imagejpeg($thumb, null, 80); + + return new Response((string) ob_get_clean(), Response::HTTP_OK, [ + 'Content-Type' => 'image/jpeg', + 'X-Content-Type-Options' => 'nosniff', + 'Content-Security-Policy' => "default-src 'none'; sandbox", + 'Cache-Control' => 'private, max-age=86400, immutable', + ]); + } + /** * q, unread, flagged, attachments, since and before (YYYY-MM-DD) into IMAP search keys. */ diff --git a/backend/tests/Integration/MessagesTest.php b/backend/tests/Integration/MessagesTest.php index c8d0ec3..44cea1f 100644 --- a/backend/tests/Integration/MessagesTest.php +++ b/backend/tests/Integration/MessagesTest.php @@ -85,6 +85,12 @@ public function testFixtureMessageWithInlineImageAndAttachments(): void self::assertResponseHeaderSame('Content-Type', 'application/octet-stream'); self::assertStringStartsWith('%PDF', (string) $this->client->getResponse()->getContent()); self::assertStringContainsString('report.pdf', (string) $this->client->getResponse()->headers->get('Content-Disposition')); + + $this->client->request('GET', sprintf('/api/folders/INBOX/messages/%d/parts/2?inline=1', $uid), server: [ + 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_REMOTE_USER' => 'alice', 'HTTP_REMOTE_EMAIL' => 'alice@example.test', + ]); + self::assertResponseHeaderSame('Content-Type', 'application/pdf'); + self::assertStringStartsWith('inline', (string) $this->client->getResponse()->headers->get('Content-Disposition')); } public function testListRowOfTheFixture(): void diff --git a/frontend/src/MailApp.tsx b/frontend/src/MailApp.tsx index 8c216ed..bb0f653 100644 --- a/frontend/src/MailApp.tsx +++ b/frontend/src/MailApp.tsx @@ -69,7 +69,7 @@ const escapeHtml = (s: string) => export type MailAppProps = { /** Where attachments are served from. Defaults to the proxy's REST shape. */ - attachmentUrl?: (ref: MessageRef, a: Attachment, disposition: 'inline' | 'download') => string + attachmentUrl?: (ref: MessageRef, a: Attachment, disposition: 'inline' | 'download' | 'thumb') => string } export function MailApp({ diff --git a/frontend/src/components/AttachmentList.module.css b/frontend/src/components/AttachmentList.module.css index a45df23..9266c42 100644 --- a/frontend/src/components/AttachmentList.module.css +++ b/frontend/src/components/AttachmentList.module.css @@ -4,13 +4,32 @@ align-items: center; gap: 8px; padding: 0 4px 0 10px; + max-width: 100%; +} + +.chipName { + min-width: 0; +} + +.size { + flex-shrink: 0; } .icon { display: inline-flex; + flex-shrink: 0; color: var(--mantine-color-dimmed); } +.thumb { + width: 28px; + height: 28px; + flex-shrink: 0; + margin-left: -4px; + border-radius: 4px; + object-fit: cover; +} + .list { list-style: none; margin: 0; diff --git a/frontend/src/components/AttachmentList.tsx b/frontend/src/components/AttachmentList.tsx index dfd1975..33f1620 100644 --- a/frontend/src/components/AttachmentList.tsx +++ b/frontend/src/components/AttachmentList.tsx @@ -1,14 +1,109 @@ import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, Tooltip } from '@mantine/core' -import { DownloadIcon, EyeIcon, FileIcon, FileTextIcon, ImageIcon, XIcon } from 'lucide-react' +import { useState } from 'react' +import { + CalendarIcon, + ContactIcon, + DownloadIcon, + EyeIcon, + FileArchiveIcon, + FileCodeIcon, + FileHeadphoneIcon, + FileIcon, + FileImageIcon, + FilePlayIcon, + FileSpreadsheetIcon, + FileTextIcon, + FileTypeIcon, + MailIcon, + PresentationIcon, + XIcon, + type LucideIcon, +} from 'lucide-react' import type { Attachment } from '../types' import { formatSize } from '../lib/format' import { useI18n } from '../lib/i18n' import classes from './AttachmentList.module.css' -function MimeIcon({ mime }: { mime: string }) { - if (mime.startsWith('image/')) return - if (mime === 'application/pdf') return - return +type Kind = { icon: LucideIcon; color?: string } + +const KINDS: { kind: Kind; mime: RegExp; ext: string[] }[] = [ + { kind: { icon: FileTextIcon, color: 'red' }, mime: /^application\/pdf$/, ext: ['pdf'] }, + { kind: { icon: FileImageIcon, color: 'grape' }, mime: /^image\//, ext: ['heic'] }, + { + kind: { icon: FileSpreadsheetIcon, color: 'green' }, + mime: /spreadsheet|excel|^text\/csv$/, + ext: ['xls', 'xlsx', 'ods', 'csv', 'numbers'], + }, + { + kind: { icon: FileTypeIcon, color: 'blue' }, + mime: /wordprocessing|msword|opendocument\.text|rtf/, + ext: ['doc', 'docx', 'odt', 'rtf', 'pages'], + }, + { + kind: { icon: PresentationIcon, color: 'orange' }, + mime: /presentation|powerpoint/, + ext: ['ppt', 'pptx', 'odp', 'key'], + }, + { + kind: { icon: FileArchiveIcon, color: 'yellow' }, + mime: /zip|compressed|x-tar|x-rar|x-bzip/, + ext: ['zip', 'gz', 'tgz', 'tar', 'rar', '7z', 'bz2'], + }, + { kind: { icon: FileHeadphoneIcon, color: 'cyan' }, mime: /^audio\//, ext: [] }, + { kind: { icon: FilePlayIcon, color: 'pink' }, mime: /^video\//, ext: [] }, + { kind: { icon: CalendarIcon, color: 'teal' }, mime: /^text\/calendar$/, ext: ['ics'] }, + { kind: { icon: ContactIcon, color: 'teal' }, mime: /vcard/, ext: ['vcf'] }, + { kind: { icon: MailIcon }, mime: /^message\//, ext: ['eml'] }, + { + kind: { icon: FileCodeIcon }, + mime: /json|xml|^text\/(html|css|javascript)$/, + ext: ['json', 'xml', 'html', 'js', 'ts', 'py', 'php', 'sh', 'sql', 'yaml', 'yml'], + }, + { kind: { icon: FileTextIcon }, mime: /^text\//, ext: ['txt', 'md', 'log'] }, +] + +/** Many clients send application/octet-stream, so the extension gets a say too. */ +function kindOf(mime: string, name: string): Kind { + const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : '' + const found = + KINDS.find((k) => k.mime.test(mime.toLowerCase())) ?? KINDS.find((k) => k.ext.includes(ext)) + return found?.kind ?? { icon: FileIcon } +} + +/** What the part endpoint agrees to serve inline. */ +function previewable(a: Attachment): boolean { + return ( + a.mime.startsWith('image/') || + a.mime === 'application/pdf' || + a.name.toLowerCase().endsWith('.pdf') + ) +} + +function MimeIcon({ mime, name }: { mime: string; name: string }) { + const { icon: Icon, color } = kindOf(mime, name) + return ( + + + + ) +} + +/** The server's thumbnail for an image, the type icon when there is none. */ +function Thumb({ mime, name, src }: { mime: string; name: string; src: string }) { + const [failed, setFailed] = useState(false) + if (!mime.startsWith('image/') || failed) return + return ( + setFailed(true)} + /> + ) } export type UploadItem = { @@ -26,7 +121,7 @@ type ReadProps = { mode: 'read' attachments: Attachment[] /** URL for an attachment, `inline` for preview, `download` for Content-Disposition: attachment. */ - urlFor: (a: Attachment, disposition: 'inline' | 'download') => string + urlFor: (a: Attachment, disposition: 'inline' | 'download' | 'thumb') => string } type ComposeProps = { @@ -51,26 +146,26 @@ export function AttachmentList(props: ReadProps | ComposeProps) { {files.map((a) => ( - - - - + + {a.name} - + {formatSize(a.size, locale)} - - - - - + {previewable(a) && ( + + + + + + )} - - - + {f.name} diff --git a/frontend/src/components/Reader.tsx b/frontend/src/components/Reader.tsx index a6130c5..9f33a5f 100644 --- a/frontend/src/components/Reader.tsx +++ b/frontend/src/components/Reader.tsx @@ -61,7 +61,7 @@ export type ReaderProps = { isMobile: boolean selfEmail: string moveTargets: MoveTarget[] - urlFor: (a: Attachment, disposition: 'inline' | 'download') => string + urlFor: (a: Attachment, disposition: 'inline' | 'download' | 'thumb') => string onAction: (action: RowAction) => void onBack?: () => void onAlwaysShowImages: (senderEmail: string) => void diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8a37222..582d73f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -57,7 +57,11 @@ createRoot(document.getElementById('root')!).render( partUrl(ref, a.id, disposition === 'inline')} + attachmentUrl={(ref, a, disposition) => + disposition === 'thumb' + ? `${partUrl(ref, a.id, true)}&thumb=1` + : partUrl(ref, a.id, disposition === 'inline') + } /> From 3a48f0368abbd74bd6763e20a56dcb06be9c7a0e Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 10 Nov 2026 23:52:51 +0100 Subject: [PATCH 4/4] [CI] Fix Psalm and Prettier --- backend/src/Controller/MessagesController.php | 3 +++ frontend/src/MailApp.tsx | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/Controller/MessagesController.php b/backend/src/Controller/MessagesController.php index 13df103..a544b12 100644 --- a/backend/src/Controller/MessagesController.php +++ b/backend/src/Controller/MessagesController.php @@ -153,6 +153,9 @@ private function thumbnail(string $bytes): Response $width = max(1, (int) round($size[0] * $scale)); $height = max(1, (int) round($size[1] * $scale)); $thumb = imagecreatetruecolor($width, $height); + if (false === $thumb) { + throw new NotFoundHttpException('No thumbnail'); + } // transparent PNGs would come out black otherwise imagefill($thumb, 0, 0, (int) imagecolorallocate($thumb, 255, 255, 255)); imagecopyresampled($thumb, $image, 0, 0, 0, 0, $width, $height, $size[0], $size[1]); diff --git a/frontend/src/MailApp.tsx b/frontend/src/MailApp.tsx index bb0f653..f5cdb6d 100644 --- a/frontend/src/MailApp.tsx +++ b/frontend/src/MailApp.tsx @@ -69,7 +69,11 @@ const escapeHtml = (s: string) => export type MailAppProps = { /** Where attachments are served from. Defaults to the proxy's REST shape. */ - attachmentUrl?: (ref: MessageRef, a: Attachment, disposition: 'inline' | 'download' | 'thumb') => string + attachmentUrl?: ( + ref: MessageRef, + a: Attachment, + disposition: 'inline' | 'download' | 'thumb', + ) => string } export function MailApp({