Resize avatar images before embedding

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.
This commit is contained in:
2026-05-15 12:24:28 +02:00
parent 12b44edfa5
commit 51c1e36125
@@ -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 ~515 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;