From a07184ab3aad537df21ae58f0063a3cd6b9e2b78 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 12:14:29 +0200 Subject: [PATCH] Embed circular profile pictures in newsletter emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull each individual's highlighted media image via webtrees' Individual::findHighlightedMediaFile, attach as Symfony inline parts with stable cid:avatar- identifiers, and render border-radius:50% on the . Couples on anniversaries show both spouses' circles side-by-side. Fallback when no image is available (privacy-hidden record, no OBJE, external URL, unreadable file): a CSS-only coloured circle with the person's initials. The hue is derived from a hash of the XREF so the same person keeps the same colour across newsletters. Done via a NewsletterMailer subclass of EmailService that adds a sendWithEmbeds() method — the parent's transport() and DKIM config still apply, only the message-construction path differs. --- resources/views/email.phtml | 118 +++++++++++++++-- src/Services/NewsletterDispatchService.php | 146 ++++++++++++++++++++- src/Services/NewsletterMailer.php | 110 ++++++++++++++++ 3 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 src/Services/NewsletterMailer.php diff --git a/resources/views/email.phtml b/resources/views/email.phtml index d2d8097..93c99fe 100644 --- a/resources/views/email.phtml +++ b/resources/views/email.phtml @@ -20,9 +20,83 @@ use Illuminate\Support\Collection; * @var int $lookahead_days * @var int $historical_lookahead * @var int $generated_at + * @var array $avatar_cids xref => CID name * @var string $account_url */ +$avatar_size = 48; + +/** + * Inline HTML for a single circular avatar. + * + * Renders an if the dispatch service was able to + * resolve an image for the individual; otherwise renders a coloured + * circle with the person's initials. The placeholder is intentionally + * CSS-only — inline SVG and data: URIs are unreliable in Outlook / + * some webmail clients. + */ +$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size): string { + if (!$individual instanceof Individual) { + return ''; + } + + $alt = e(strip_tags($individual->fullName())); + + if (isset($avatar_cids[$individual->xref()])) { + $cid = $avatar_cids[$individual->xref()]; + + return '' . $alt . ''; + } + + // CSS-only fallback: coloured circle with initials. Hash the xref + // into a stable hue so each person keeps the same colour across + // newsletters. + $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; + $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); + $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); + $i1 = mb_substr($first, 0, 1); + $i2 = mb_substr($last, 0, 1); + $initials = e(mb_strtoupper($i1 . $i2)); + + return '' . $initials . ''; +}; + +/** + * HTML for the avatar(s) attached to a Fact's primary record: + * a single circle for Individual facts, side-by-side circles for + * Family facts (anniversaries). + */ +$record_avatars = static function (Fact $fact) use ($avatar): string { + $record = $fact->record(); + + if ($record instanceof Individual) { + return $avatar($record); + } + + if ($record instanceof Family) { + $parts = []; + + foreach ([$record->husband(), $record->wife()] as $spouse) { + if ($spouse instanceof Individual) { + $parts[] = $avatar($spouse); + } + } + + // Slight negative margin so the two circles overlap a touch — + // visually communicates "couple" without needing extra glue. + return '' + . implode('', $parts) + . ''; + } + + return ''; +}; + $record_label = static function (Fact $fact): string { $record = $fact->record(); @@ -141,15 +215,25 @@ $anniversary_label = static function (int $age) use ($ordinal): string {

+ + isEmpty()) : ?>

-
    +
      -
    • - - — - () +
    • + + + + — + () +
    @@ -157,13 +241,16 @@ $anniversary_label = static function (int $age) use ($ordinal): string { isEmpty()) : ?>

    -
      +
        -
      • - - — - () +
      • + + + + — + () +
      @@ -174,11 +261,14 @@ $anniversary_label = static function (int $age) use ($ordinal): string {

      -
        +
          -
        • - - — label()) ?>: +
        • + + + + — label()) ?>: +
        diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index 4d0a31c..569bfd0 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -6,16 +6,20 @@ namespace EmailNewsletter\Services; use EmailNewsletter\Configuration; use Fisharebest\Webtrees\Contracts\UserInterface; +use Fisharebest\Webtrees\Fact; +use Fisharebest\Webtrees\Family; use Fisharebest\Webtrees\I18N; +use Fisharebest\Webtrees\Individual; use Fisharebest\Webtrees\Log; use Fisharebest\Webtrees\Module\ModuleInterface; -use Fisharebest\Webtrees\Services\EmailService; use Fisharebest\Webtrees\Services\TreeService; use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\User; +use Illuminate\Support\Collection; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mime\Exception\RfcComplianceException; +use Throwable; /** * Decides which trees are due, builds per-tree newsletters, and dispatches @@ -36,7 +40,7 @@ final class NewsletterDispatchService public function __construct( private readonly EventQueryService $event_query_service, - private readonly EmailService $email_service, + private readonly NewsletterMailer $mailer, private readonly TreeService $tree_service, private readonly UserService $user_service, ) { @@ -115,6 +119,8 @@ final class NewsletterDispatchService $from = $this->siteContact($tree); $original_locale = I18N::languageTag(); $groups = $this->groupRecipientsByLanguage($recipients); + $avatars = $this->collectAvatars($birthdays, $anniversaries, $historical); + $avatar_cids = $this->avatarCids($avatars); $sent = 0; $failures = 0; @@ -138,6 +144,7 @@ final class NewsletterDispatchService 'lookahead_days' => $lookahead, 'historical_lookahead' => $historical_lookahead, 'generated_at' => $now, + 'avatar_cids' => $avatar_cids, 'account_url' => route( \Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class, ['tree' => $tree->name()], @@ -148,7 +155,7 @@ final class NewsletterDispatchService foreach ($group as $recipient) { try { - if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) { + if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) { $sent++; } else { $failures++; @@ -179,6 +186,137 @@ final class NewsletterDispatchService ); } + /** + * Resolve a "highlighted" image for every individual mentioned in + * the newsletter and return a CID-keyed map of bytes + MIME type + * that NewsletterMailer can embed. + * + * @param Collection|null $anniversaries + * @param Collection|null $historical + * + * @return array + */ + private function collectAvatars( + Collection $birthdays, + Collection|null $anniversaries, + Collection|null $historical, + ): array { + $individuals = []; + + foreach ($birthdays as $fact) { + $this->indexIndividualsFromFact($fact, $individuals); + } + + if ($anniversaries !== null) { + foreach ($anniversaries as $fact) { + $this->indexIndividualsFromFact($fact, $individuals); + } + } + + if ($historical !== null) { + foreach ($historical as $fact) { + $this->indexIndividualsFromFact($fact, $individuals); + } + } + + $avatars = []; + + foreach ($individuals as $xref => $individual) { + $payload = $this->resolveAvatar($individual); + + if ($payload !== null) { + $avatars[$this->avatarCidName($xref)] = $payload; + } + } + + return $avatars; + } + + /** + * @param array $bag + */ + private function indexIndividualsFromFact(Fact $fact, array &$bag): void + { + $record = $fact->record(); + + if ($record instanceof Individual) { + $bag[$record->xref()] = $record; + return; + } + + if ($record instanceof Family) { + foreach ([$record->husband(), $record->wife()] as $spouse) { + if ($spouse instanceof Individual) { + $bag[$spouse->xref()] = $spouse; + } + } + } + } + + /** + * @return array{bytes:string,mime:string}|null + */ + private function resolveAvatar(Individual $individual): array|null + { + try { + $media_file = $individual->findHighlightedMediaFile(); + } catch (Throwable $ex) { + Log::addErrorLog('Newsletter avatar lookup failed for ' . $individual->xref() . ': ' . $ex->getMessage()); + return null; + } + + if ($media_file === null) { + return null; + } + + // External URLs cannot be embedded as inline parts. Skipping + // these gracefully falls back to the placeholder silhouette. + if ($media_file->isExternal() || !$media_file->isImage()) { + return null; + } + + try { + $bytes = $media_file->fileContents(); + } catch (Throwable $ex) { + Log::addErrorLog('Newsletter avatar read failed for ' . $individual->xref() . ': ' . $ex->getMessage()); + return null; + } + + if ($bytes === '') { + return null; + } + + return [ + 'bytes' => $bytes, + 'mime' => $media_file->mimeType() ?: 'application/octet-stream', + ]; + } + + /** + * Build the map the view consults: xref -> CID name. Only entries + * for individuals with a successfully resolved avatar are present; + * the view treats absence as "use the placeholder". + * + * @param array $avatars + * @return array + */ + private function avatarCids(array $avatars): array + { + $cids = []; + + foreach (array_keys($avatars) as $cid_name) { + // CID name is "avatar-{xref}" — reverse the prefix to recover the xref. + $cids[substr($cid_name, strlen('avatar-'))] = $cid_name; + } + + return $cids; + } + + private function avatarCidName(string $xref): string + { + return 'avatar-' . $xref; + } + /** * Group recipients by the language we will render their email in. * German users get "de"; everyone else (including admin-added @@ -240,7 +378,7 @@ final class NewsletterDispatchService foreach (Configuration::extraRecipients($tree) as $email) { $key = strtolower($email); - if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) { + if (isset($seen[$key]) || !$this->mailer->isValidEmail($email)) { continue; } diff --git a/src/Services/NewsletterMailer.php b/src/Services/NewsletterMailer.php new file mode 100644 index 0000000..0398a00 --- /dev/null +++ b/src/Services/NewsletterMailer.php @@ -0,0 +1,110 @@ + $embeds Keyed by CID name (without "@host" + * suffix); referenced in HTML as + * ``. + */ + public function sendWithEmbeds( + UserInterface $from, + UserInterface $to, + UserInterface $reply_to, + string $subject, + string $message_text, + string $message_html, + array $embeds, + ): bool { + try { + $message = $this->buildMessage($from, $to, $reply_to, $subject, $message_text, $message_html, $embeds); + $transport = $this->transport(); + $mailer = new Mailer($transport); + $mailer->send($message); + } catch (RfcComplianceException $ex) { + Log::addErrorLog('Cannot create newsletter email: ' . $ex->getMessage()); + + return false; + } catch (TransportExceptionInterface $ex) { + Log::addErrorLog('Cannot send newsletter email: ' . $ex->getMessage()); + + return false; + } + + return true; + } + + /** + * Mirrors the parent's message() builder, but with inline image + * parts and without the multipart/alternative DKIM workaround + * (DKIM still works because we sign after attachments are added). + * + * @param array $embeds + */ + private function buildMessage( + UserInterface $from, + UserInterface $to, + UserInterface $reply_to, + string $subject, + string $message_text, + string $message_html, + array $embeds, + ): Message { + $message_text = str_replace("\n", "\r\n", $message_text); + $message_html = str_replace("\n", "\r\n", $message_html); + + $email = (new Email()) + ->subject($subject) + ->from(new Address($from->email(), $from->realName())) + ->to(new Address($to->email(), $to->realName())) + ->replyTo(new Address($reply_to->email(), $reply_to->realName())) + ->text($message_text) + ->html($message_html); + + foreach ($embeds as $name => $avatar) { + $email->embed($avatar['bytes'], $name, $avatar['mime']); + } + + $dkim_domain = Site::getPreference('DKIM_DOMAIN'); + $dkim_selector = Site::getPreference('DKIM_SELECTOR'); + $dkim_key = Site::getPreference('DKIM_KEY'); + + if ($dkim_domain !== '' && $dkim_selector !== '' && $dkim_key !== '') { + $signer = new DkimSigner($dkim_key, $dkim_domain, $dkim_selector); + $options = (new DkimOptions()) + ->headerCanon('relaxed') + ->bodyCanon('relaxed'); + + return $signer->sign($email, $options->toArray()); + } + + return $email; + } +}