Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion backend/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 56 additions & 4 deletions backend/src/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -119,6 +134,43 @@ 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);
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]);

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.
*/
Expand Down
36 changes: 33 additions & 3 deletions backend/src/Imap/Protocol/BodyStructure.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/Mail/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <meta charset> (Outlook loves iso-8859-1) and would decode it a second time.
$html = preg_replace('/<meta\b[^>]*>/i', '', $html) ?? $html;

return [
'html' => $this->mail->sanitizeFor('body', $html),
'hasRemoteImages' => $hasRemoteImages,
Expand Down
14 changes: 14 additions & 0 deletions backend/tests/Imap/Protocol/BodyStructureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
6 changes: 6 additions & 0 deletions backend/tests/Integration/MessagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions backend/tests/Mail/HtmlRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public function testEverythingActiveIsGone(): void
self::assertStringContainsString('Overlay', $html);
}

public function testMetaCharsetDoesNotReDecodeTheBody(): void
{
$source = '<html><head><meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"></head><body><p>Envoyé à J’ai</p></body></html>';
['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('<p><img src="https://img.example/a.png" alt="a"></p>', static fn () => null, true);
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/MailApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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') => string
attachmentUrl?: (
ref: MessageRef,
a: Attachment,
disposition: 'inline' | 'download' | 'thumb',
) => string
}

export function MailApp({
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/AttachmentList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading