Embed circular profile pictures in newsletter emails

Pull each individual's highlighted media image via webtrees'
Individual::findHighlightedMediaFile, attach as Symfony inline
parts with stable cid:avatar-<xref> identifiers, and render
border-radius:50% on the <img>. 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.
This commit is contained in:
2026-05-15 12:14:29 +02:00
parent 7ce8201082
commit a07184ab3a
3 changed files with 356 additions and 18 deletions
+96 -6
View File
@@ -20,9 +20,83 @@ use Illuminate\Support\Collection;
* @var int $lookahead_days * @var int $lookahead_days
* @var int $historical_lookahead * @var int $historical_lookahead
* @var int $generated_at * @var int $generated_at
* @var array<string,string> $avatar_cids xref => CID name
* @var string $account_url * @var string $account_url
*/ */
$avatar_size = 48;
/**
* Inline HTML for a single circular avatar.
*
* Renders an <img src="cid:..."> 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 '<img src="cid:' . e($cid) . '" alt="' . $alt . '" width="' . $avatar_size . '" height="' . $avatar_size . '"'
. ' style="border-radius:50%;object-fit:cover;vertical-align:middle;border:1px solid #ccc;">';
}
// 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 '<span aria-label="' . $alt . '"'
. ' style="display:inline-block;width:' . $avatar_size . 'px;height:' . $avatar_size . 'px;'
. 'border-radius:50%;background:hsl(' . (int) $hue . ',45%,60%);color:#fff;'
. 'font:600 18px/' . $avatar_size . 'px Helvetica,Arial,sans-serif;text-align:center;'
. 'vertical-align:middle;letter-spacing:0.5px;">' . $initials . '</span>';
};
/**
* 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 '<span style="display:inline-block;white-space:nowrap;">'
. implode('<span style="display:inline-block;width:6px;"></span>', $parts)
. '</span>';
}
return '';
};
$record_label = static function (Fact $fact): string { $record_label = static function (Fact $fact): string {
$record = $fact->record(); $record = $fact->record();
@@ -141,15 +215,25 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
<?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?> <?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
</p> </p>
<?php
// Inline list style: drop bullets, add vertical spacing so the avatars
// don't crash into each other.
$list_style = 'list-style:none;padding:0;margin:0.5rem 0 1.5rem;';
$item_style = 'display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0;border-bottom:1px solid #eee;';
?>
<?php if (!$birthdays->isEmpty()) : ?> <?php if (!$birthdays->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2> <h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
<ul> <ul style="<?= $list_style ?>">
<?php foreach ($birthdays as $fact) : ?> <?php foreach ($birthdays as $fact) : ?>
<?php $age = $upcoming_age($fact); ?> <?php $age = $upcoming_age($fact); ?>
<li> <li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong> <strong><?= e($record_label($fact)) ?></strong>
<?= e($birthday_label($age)) ?> <?= e($birthday_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span> <span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</span>
</li> </li>
<?php endforeach ?> <?php endforeach ?>
</ul> </ul>
@@ -157,13 +241,16 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?> <?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2> <h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
<ul> <ul style="<?= $list_style ?>">
<?php foreach ($anniversaries as $fact) : ?> <?php foreach ($anniversaries as $fact) : ?>
<?php $age = $upcoming_age($fact); ?> <?php $age = $upcoming_age($fact); ?>
<li> <li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong> <strong><?= e($record_label($fact)) ?></strong>
<?= e($anniversary_label($age)) ?> <?= e($anniversary_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span> <span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</span>
</li> </li>
<?php endforeach ?> <?php endforeach ?>
</ul> </ul>
@@ -174,11 +261,14 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
<p style="color: #666;"> <p style="color: #666;">
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?> <?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
</p> </p>
<ul> <ul style="<?= $list_style ?>">
<?php foreach ($historical as $fact) : ?> <?php foreach ($historical as $fact) : ?>
<li> <li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong> <strong><?= e($record_label($fact)) ?></strong>
<?= e($fact->label()) ?>: <?= e($event_date($fact)) ?> <?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
</span>
</li> </li>
<?php endforeach ?> <?php endforeach ?>
</ul> </ul>
+142 -4
View File
@@ -6,16 +6,20 @@ namespace EmailNewsletter\Services;
use EmailNewsletter\Configuration; use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Contracts\UserInterface; use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Fact;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Log; use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Module\ModuleInterface; use Fisharebest\Webtrees\Module\ModuleInterface;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\TreeService; use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService; use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User; use Fisharebest\Webtrees\User;
use Illuminate\Support\Collection;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Exception\RfcComplianceException; use Symfony\Component\Mime\Exception\RfcComplianceException;
use Throwable;
/** /**
* Decides which trees are due, builds per-tree newsletters, and dispatches * Decides which trees are due, builds per-tree newsletters, and dispatches
@@ -36,7 +40,7 @@ final class NewsletterDispatchService
public function __construct( public function __construct(
private readonly EventQueryService $event_query_service, private readonly EventQueryService $event_query_service,
private readonly EmailService $email_service, private readonly NewsletterMailer $mailer,
private readonly TreeService $tree_service, private readonly TreeService $tree_service,
private readonly UserService $user_service, private readonly UserService $user_service,
) { ) {
@@ -115,6 +119,8 @@ final class NewsletterDispatchService
$from = $this->siteContact($tree); $from = $this->siteContact($tree);
$original_locale = I18N::languageTag(); $original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients); $groups = $this->groupRecipientsByLanguage($recipients);
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
$avatar_cids = $this->avatarCids($avatars);
$sent = 0; $sent = 0;
$failures = 0; $failures = 0;
@@ -138,6 +144,7 @@ final class NewsletterDispatchService
'lookahead_days' => $lookahead, 'lookahead_days' => $lookahead,
'historical_lookahead' => $historical_lookahead, 'historical_lookahead' => $historical_lookahead,
'generated_at' => $now, 'generated_at' => $now,
'avatar_cids' => $avatar_cids,
'account_url' => route( 'account_url' => route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class, \Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()], ['tree' => $tree->name()],
@@ -148,7 +155,7 @@ final class NewsletterDispatchService
foreach ($group as $recipient) { foreach ($group as $recipient) {
try { try {
if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) { if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $avatars)) {
$sent++; $sent++;
} else { } else {
$failures++; $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<int,Fact>|null $anniversaries
* @param Collection<int,Fact>|null $historical
*
* @return array<string,array{bytes:string,mime:string}>
*/
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<string,Individual> $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<string,array{bytes:string,mime:string}> $avatars
* @return array<string,string>
*/
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. * Group recipients by the language we will render their email in.
* German users get "de"; everyone else (including admin-added * German users get "de"; everyone else (including admin-added
@@ -240,7 +378,7 @@ final class NewsletterDispatchService
foreach (Configuration::extraRecipients($tree) as $email) { foreach (Configuration::extraRecipients($tree) as $email) {
$key = strtolower($email); $key = strtolower($email);
if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) { if (isset($seen[$key]) || !$this->mailer->isValidEmail($email)) {
continue; continue;
} }
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Site;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Crypto\DkimOptions;
use Symfony\Component\Mime\Crypto\DkimSigner;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Exception\RfcComplianceException;
use Symfony\Component\Mime\Message;
/**
* Thin extension of the built-in EmailService that supports inline
* image embeds (used for circular avatars in the newsletter).
*
* We re-use the protected transport() method from the parent so the
* existing webtrees SMTP / sendmail configuration (and DKIM signing)
* still applies — only the message-construction step differs.
*
* @phpstan-type Avatar array{bytes:string,mime:string}
*/
final class NewsletterMailer extends EmailService
{
/**
* @param array<string,Avatar> $embeds Keyed by CID name (without "@host"
* suffix); referenced in HTML as
* `<img src="cid:KEY">`.
*/
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<string,Avatar> $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;
}
}