From 51c1e361255b7ae32ae9e68e1723a4330b7a396d Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 12:24:28 +0200 Subject: [PATCH] Resize avatar images before embedding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source media files can easily be multiple megabytes — embedding the originals made a per-recipient email balloon to 10MB+. Each avatar is now cover-cropped to 96x96 (HiDPI for the rendered 48px circle) and re-encoded as JPEG q=75 via Intervention\Image, which webtrees already depends on. Typical avatar payload drops from megabytes to ~5-15KB. Falls back to the original bytes (with a log warning) if neither Imagick nor GD is loaded — better an oversized email than none. --- src/Services/NewsletterDispatchService.php | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index 8d8662f..7ccd924 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -17,6 +17,10 @@ use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\User; use Illuminate\Support\Collection; +use Intervention\Image\Drivers\Gd\Driver as GdDriver; +use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; +use Intervention\Image\Encoders\JpegEncoder; +use Intervention\Image\ImageManager; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mime\Exception\RfcComplianceException; use Throwable; @@ -38,6 +42,22 @@ final class NewsletterDispatchService */ private const string FROM_NAME = 'webtrees newsletter'; + /** + * Target dimensions for embedded avatars, in pixels. Rendered at 48 + * CSS pixels, so 96 covers HiDPI displays. Larger source images are + * cover-cropped down; smaller ones are left untouched. + */ + private const int AVATAR_SIZE = 96; + + /** + * JPEG quality used when re-encoding resized avatars. 75 is a good + * size/quality trade-off for small portraits. + */ + private const int AVATAR_JPEG_QUALITY = 75; + + private ImageManager|null $image_manager = null; + private bool $image_manager_resolved = false; + public function __construct( private readonly EventQueryService $event_query_service, private readonly NewsletterMailer $mailer, @@ -293,12 +313,80 @@ final class NewsletterDispatchService return null; } + $resized = $this->resizeAvatar($bytes); + + if ($resized !== null) { + return $resized; + } + + // Fall back to the original bytes if no image library is + // available — better an oversized email than no avatar at all. return [ 'bytes' => $bytes, 'mime' => $media_file->mimeType() ?: 'application/octet-stream', ]; } + /** + * Cover-crop the source image to a small square and re-encode it + * as JPEG. Drops a multi-MB source down to ~5–15 KB. + * + * Returns null if no image library is loaded (in which case the + * caller keeps the original bytes), or if Intervention failed to + * read the file. + * + * @return array{bytes:string,mime:string}|null + */ + private function resizeAvatar(string $bytes): array|null + { + $manager = $this->imageManager(); + + if ($manager === null) { + return null; + } + + try { + $image = $manager->read($bytes); + $encoded = $image + ->cover(self::AVATAR_SIZE, self::AVATAR_SIZE) + ->encode(new JpegEncoder(self::AVATAR_JPEG_QUALITY)) + ->toString(); + } catch (Throwable $ex) { + Log::addErrorLog('Newsletter avatar resize failed: ' . $ex->getMessage()); + return null; + } + + return [ + 'bytes' => $encoded, + 'mime' => 'image/jpeg', + ]; + } + + /** + * Lazily build (and cache) an Intervention ImageManager, preferring + * Imagick over GD — mirrors what webtrees' own ImageFactory does. + * Returns null if neither extension is present so the caller can + * gracefully fall back to the original image bytes. + */ + private function imageManager(): ImageManager|null + { + if (!$this->image_manager_resolved) { + $this->image_manager_resolved = true; + + if (extension_loaded('imagick')) { + $this->image_manager = new ImageManager(new ImagickDriver()); + } elseif (extension_loaded('gd')) { + $this->image_manager = new ImageManager(new GdDriver()); + } else { + Log::addErrorLog( + 'Newsletter: neither Imagick nor GD is available; avatars will be embedded at their original size.', + ); + } + } + + return $this->image_manager; + } + /** * Build the map the view consults: xref -> CID name. Only entries * for individuals with a successfully resolved avatar are present;