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:
@@ -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<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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user