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
+104 -14
View File
@@ -20,9 +20,83 @@ use Illuminate\Support\Collection;
* @var int $lookahead_days
* @var int $historical_lookahead
* @var int $generated_at
* @var array<string,string> $avatar_cids xref => CID name
* @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 = $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)) ?>
</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()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
<ul>
<ul style="<?= $list_style ?>">
<?php foreach ($birthdays as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($birthday_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
<li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($birthday_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</span>
</li>
<?php endforeach ?>
</ul>
@@ -157,13 +241,16 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
<ul>
<ul style="<?= $list_style ?>">
<?php foreach ($anniversaries as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($anniversary_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
<li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($anniversary_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</span>
</li>
<?php endforeach ?>
</ul>
@@ -174,11 +261,14 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
<p style="color: #666;">
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
</p>
<ul>
<ul style="<?= $list_style ?>">
<?php foreach ($historical as $fact) : ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
<li style="<?= $item_style ?>">
<?= $record_avatars($fact) ?>
<span>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
</span>
</li>
<?php endforeach ?>
</ul>