Files
webtrees_email_newsletter/src/Services/NewsletterDispatchService.php
Alexander 2f174bb229 Skip intro-author avatar embed when no intro is being sent
Two-level gate: resolve and embed the tree-contact portrait only
when (a) at least one locale on the tree has a non-empty intro on
file, and (b) the specific recipient is still pending delivery
for the current intro version. Recipients who have already seen
this intro, or whose locale has no intro, no longer carry the
extra image bytes.
2026-05-15 16:08:11 +02:00

903 lines
33 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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\AbstractModule;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Site;
use Fisharebest\Webtrees\SiteUser;
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 IntlDateFormatter;
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
* them via the webtrees EmailService.
*
* The "due" decision is based solely on stored timestamps and the
* configured frequency. It is intentionally independent of who triggered
* the run, so this is safe to call from an external cron without
* worrying about double-sends from concurrent invocations (idempotent
* within the same window, modulo a very small race the lock guards).
*/
final class NewsletterDispatchService
{
/**
* Anonymous "from" address fallback when the site has no contact user.
*/
private const string FROM_NAME = 'webtrees newsletter';
/**
* Target dimensions for embedded avatars, in pixels. Rendered at
* 56 CSS pixels in the email view, so 192 covers ~3.4× retina /
* HiDPI displays without visible blur. Larger source images are
* cover-cropped down; smaller ones are left untouched.
*/
private const int AVATAR_SIZE = 192;
/**
* JPEG quality used when re-encoding resized avatars. 88 keeps
* portraits crisp without ballooning the email size — a 192px
* face encodes to ~2540 KB at this quality.
*/
private const int AVATAR_JPEG_QUALITY = 88;
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,
private readonly TreeService $tree_service,
private readonly UserService $user_service,
private readonly RelationshipPathFinder $relationship_finder,
) {
}
/**
* Run the dispatch cycle for every tree.
*
* @return array<int,string> Human-readable log lines describing what
* happened, suitable for the cron endpoint
* to return to the caller.
*/
public function dispatch(AbstractModule $module, bool $force = false): array
{
$log = [];
$now = time();
foreach ($this->tree_service->all() as $tree) {
if (!Configuration::isEnabled($tree)) {
$log[] = sprintf('Tree "%s": disabled, skipping.', $tree->name());
continue;
}
// Per-tree "is anyone due?" is decided inside dispatchForTree
// — each recipient has their own cadence and last-sent
// timestamp, so the gate is per-user, not per-tree.
$log[] = $this->dispatchForTree($tree, $module, $now, $force);
}
return $log;
}
private function dispatchForTree(Tree $tree, AbstractModule $module, int $now, bool $force): string
{
$include_anniversaries = Configuration::includeAnniversaries($tree);
// One number controls everything: how often the newsletter is
// sent AND how far ahead each issue looks for events. Same
// window applies to living birthdays/anniversaries and to the
// historical (deceased) section.
$window = Configuration::frequencyDays($tree);
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $window);
$anniversaries = $include_anniversaries
? $this->event_query_service->upcomingAnniversaries($tree, $window)
: null;
$historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window);
// Suppress entirely empty newsletters so subscribers don't get
// a near-empty email on a slow fortnight.
$has_content = !$birthdays->isEmpty()
|| ($anniversaries !== null && !$anniversaries->isEmpty())
|| ($historical !== null && !$historical->isEmpty());
if (!$has_content) {
Configuration::setLastSentAt($tree, $now);
return sprintf('Tree "%s": nothing to report, send timestamp advanced.', $tree->name());
}
$recipients = $this->collectRecipients($tree);
if ($recipients === []) {
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
}
// Match webtrees' own convention for system-generated email
// (registration confirmations, password resets, "new version
// available" notices): the From: header is the SiteUser
// (SMTP_FROM_NAME), while Reply-To: points at the family-tree
// contact person so replies still reach a human. If the site
// admin hasn't configured SMTP_FROM_NAME we fall back to the
// tree contact for From: too, otherwise the transport may
// reject the message for lacking a sender envelope.
$reply_to = $this->siteContact($tree);
$from = $this->siteFrom($reply_to);
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
$featured = $this->collectFeaturedIndividuals($birthdays, $anniversaries, $historical);
// The intro paragraph is attributed to the tree contact user
// (the same person we use as Reply-To). If they're linked to an
// Individual record we fold their avatar into the embed set so
// the editorial block can render with their face on the left.
// We only bother when at least one locale actually has an intro
// to send — otherwise loading/encoding the portrait is wasted
// work and the bytes would be attached to every recipient's
// email without ever being referenced.
$has_any_intro = false;
foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) {
if (trim(Configuration::introForLocale($module, $tree, $code)) !== '') {
$has_any_intro = true;
break;
}
}
$intro_author = $has_any_intro ? $this->resolveIntroAuthor($tree, $reply_to) : null;
if ($intro_author instanceof Individual && !isset($avatars[$this->avatarCidName($intro_author->xref())])) {
$author_avatar = $this->resolveAvatar($intro_author);
if ($author_avatar !== null) {
$avatars[$this->avatarCidName($intro_author->xref())] = $author_avatar;
}
}
$avatar_cids = $this->avatarCids($avatars);
$account_url = route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
);
$sent = 0;
$failures = 0;
try {
foreach ($groups as $lang => $group) {
I18N::init($lang);
$subject = Configuration::subjectPrefixForLocale($tree, $lang)
. I18N::translate(
'Family newsletter — %s',
$this->formatSubjectDate($now, $lang),
);
// One-shot intro paragraph (admin-supplied). Empty
// string is "no intro" — the view simply omits the
// block. We do NOT clear it after sending; instead
// we version-stamp it and track each recipient's
// last-seen version, so subscribers on slower
// cadences still get the message exactly once.
$intro = Configuration::introForLocale($module, $tree, $lang);
$intro_version = Configuration::introVersion($module, $tree, $lang);
$external_seen = Configuration::externalIntroVersion($module, $tree, $lang);
$external_served_this_run = false;
// Render the email body per recipient — the relationship
// labels are personalised relative to whichever individual
// record the recipient is linked to in this tree.
foreach ($group as $recipient) {
if (!$this->recipientIsDue($tree, $recipient, $now, $force)) {
continue;
}
$relationships = $this->relationshipMap($tree, $recipient, $featured);
$detailed_set = $this->detailedXrefs($tree, $recipient, $featured);
// Trim the embedded image set to only the avatars
// we'll actually reference (detailed rows). Summary
// bullets render without pictures.
$recipient_avatars = array_intersect_key(
$avatars,
$this->avatarKeysForXrefs(array_keys($detailed_set)),
);
// Decide whether to attach the intro for *this*
// recipient: only if it's non-empty AND this
// recipient hasn't yet received the current
// version. Webtrees users have their own
// watermark; external addresses share a single
// tree-level one.
$recipient_seen = $recipient instanceof User
? Configuration::userIntroVersion($tree, $recipient, $lang)
: $external_seen;
$show_intro = $intro !== '' && $intro_version > $recipient_seen;
// Only attach the editorial portrait to recipients
// who actually see the intro — otherwise the bytes
// would ride along uselessly and bloat the message.
if ($show_intro && $intro_author instanceof Individual) {
$author_cid = $this->avatarCidName($intro_author->xref());
if (isset($avatars[$author_cid])) {
$recipient_avatars[$author_cid] = $avatars[$author_cid];
}
}
$personalised_intro = $show_intro
? $this->renderIntroTemplate($intro, $recipient)
: '';
$html = view($module->name() . '::email', [
'tree' => $tree,
'birthdays' => $birthdays,
'anniversaries' => $anniversaries,
'historical' => $historical,
'include_anniversaries' => $include_anniversaries,
'window_days' => $window,
'generated_at' => $now,
'avatar_cids' => $avatar_cids,
'relationships' => $relationships,
'detailed_xrefs' => $detailed_set,
'account_url' => $account_url,
'intro' => $personalised_intro,
'intro_author' => $show_intro ? $intro_author : null,
]);
$text = $this->htmlToText($html);
try {
if ($this->mailer->sendWithEmbeds($from, $recipient, $reply_to, $subject, $text, $html, $recipient_avatars)) {
$sent++;
// Per-user last-sent is stored only for real
// webtrees users (id > 0). External admin-
// added addresses always fire on every run.
if ($recipient instanceof User) {
Configuration::setUserLastSentAt($tree, $recipient, $now);
// Mark this user as up-to-date on the
// intro so we don't re-deliver it next
// time their cadence comes round.
if ($show_intro) {
Configuration::setUserIntroVersion($tree, $recipient, $lang, $intro_version);
}
} elseif ($show_intro) {
// External recipient — bump the single
// tree-level "externals served"
// watermark after the loop so all
// externals in this run see the same
// intro before we move it forward.
$external_served_this_run = true;
}
} else {
$failures++;
}
} catch (RfcComplianceException | TransportExceptionInterface $ex) {
$failures++;
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
}
}
if ($external_served_this_run) {
Configuration::setExternalIntroVersion($module, $tree, $lang, $intro_version);
}
}
} finally {
// Always restore the original locale, even if a render or
// send throws an unexpected exception.
I18N::init($original_locale);
}
// Tree-level last-sent is kept for the admin "Last sent" line
// on the preferences page; it no longer gates dispatch.
if ($sent > 0 || $failures > 0) {
Configuration::setLastSentAt($tree, $now);
}
return sprintf(
'Tree "%s": sent to %d recipient(s), %d failure(s).',
$tree->name(),
$sent,
$failures,
);
}
/**
* True if the recipient's own cadence has elapsed since their
* last newsletter (or if `$force` is set). External non-user
* recipients have no per-user timestamp — they always fire so
* admin-managed mailing lists still get an issue every run.
*/
private function recipientIsDue(Tree $tree, UserInterface $recipient, int $now, bool $force): bool
{
if ($force) {
return true;
}
if (!$recipient instanceof User) {
return true;
}
$last = Configuration::userLastSentAt($tree, $recipient);
$freq = Configuration::effectiveFrequencyDays($tree, $recipient);
$due_at = $last + $freq * 86400;
return $now >= $due_at;
}
/**
* 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}>
*/
/**
* Set of Individual records that appear in any of the newsletter
* sections. Used by the relationship-map builder so each row can
* be labelled "your mother", "3rd cousin once removed", etc.
*
* @param Collection<int,Fact>|null $anniversaries
* @param Collection<int,Fact>|null $historical
*
* @return array<string,Individual> xref => Individual
*/
private function collectFeaturedIndividuals(
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);
}
}
return $individuals;
}
/**
* Which featured xrefs deserve the full detailed row (avatar +
* timeline) for this recipient?
*
* If the recipient is unmapped to the tree (external address or a
* user with no PREF_TREE_ACCOUNT_XREF), every featured xref counts
* as "detailed" — they have no lineal context to filter against.
*
* Otherwise, only the recipient's direct ancestors and descendants
* within Configuration::linealDepth() generations qualify. For
* Family records (anniversaries), either spouse being lineal
* promotes the row.
*
* @param array<string,Individual> $featured
*
* @return array<string,true> Set of featured xrefs to render in detail.
*/
private function detailedXrefs(Tree $tree, UserInterface $recipient, array $featured): array
{
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
// Unmapped recipient — no lineal anchor, show everything.
return array_fill_keys(array_keys($featured), true);
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return array_fill_keys(array_keys($featured), true);
}
$max_distance = Configuration::linealDepth($tree);
$distances = $this->relationship_finder->kinDistances($self, $max_distance);
$detailed = [];
foreach ($featured as $xref => $_individual) {
if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) {
$detailed[$xref] = true;
}
}
return $detailed;
}
/**
* Translates a list of xrefs into the cid-name keys used by the
* avatar embed map ("avatar-<xref>" => true). Lets us
* array_intersect_key the embeds map cheaply.
*
* @param array<int,string> $xrefs
* @return array<string,true>
*/
private function avatarKeysForXrefs(array $xrefs): array
{
$keys = [];
foreach ($xrefs as $xref) {
$keys['avatar-' . $xref] = true;
}
return $keys;
}
/**
* Build a "xref => relationship label" map for one recipient.
*
* Returns an empty map for recipients we cannot label: external
* addresses (no webtrees account), users with no linked Individual
* record on this tree, or users whose linked record can't be
* resolved (privacy-hidden, broken xref).
*
* @param array<string,Individual> $featured
*
* @return array<string,string>
*/
private function relationshipMap(Tree $tree, UserInterface $recipient, array $featured): array
{
$self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
return [];
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return [];
}
$map = [];
foreach ($featured as $xref => $individual) {
$label = $this->relationship_finder->label($self, $individual);
if ($label !== null && $label !== '') {
$map[$xref] = $label;
}
}
return $map;
}
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;
}
$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;
* 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
* external addresses) gets "en-US" for now.
*
* @param array<int,UserInterface> $recipients
* @return array<string,array<int,UserInterface>>
*/
private function groupRecipientsByLanguage(array $recipients): array
{
$groups = [];
foreach ($recipients as $recipient) {
$pref = $recipient->getPreference(UserInterface::PREF_LANGUAGE, '');
$lang = str_starts_with(strtolower($pref), 'de') ? 'de' : 'en-US';
$groups[$lang][] = $recipient;
}
return $groups;
}
/**
* @return array<int,UserInterface>
*/
private function collectRecipients(Tree $tree): array
{
$recipients = [];
$seen = [];
foreach ($this->user_service->all() as $user) {
if (!$user instanceof User) {
continue;
}
// Only deliver to approved + email-verified accounts.
if ($user->getPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
continue;
}
if ($user->getPreference(UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
continue;
}
if ($tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) !== '1') {
continue;
}
$email = trim($user->email());
if ($email === '' || isset($seen[strtolower($email)])) {
continue;
}
$seen[strtolower($email)] = true;
$recipients[] = $user;
}
foreach (Configuration::extraRecipients($tree) as $email) {
$key = strtolower($email);
if (isset($seen[$key]) || !$this->mailer->isValidEmail($email)) {
continue;
}
$seen[$key] = true;
$recipients[] = new ExtraRecipient($email);
}
return $recipients;
}
/**
* Resolve the From: identity, mirroring webtrees' own behaviour:
* the SMTP_FROM_NAME / SMTP_DISP_NAME site preferences if set,
* otherwise the tree contact user (so the transport always has
* a usable envelope sender). This is the address recipients see
* in their mail client, not the one their replies go to.
*/
private function siteFrom(UserInterface $fallback): UserInterface
{
if (trim(Site::getPreference('SMTP_FROM_NAME')) !== '') {
return new SiteUser();
}
return $fallback;
}
/**
* Pick a sender identity. Fall back to a synthetic UserInterface if
* the site has no contact user configured for this tree.
*/
private function siteContact(Tree $tree): UserInterface
{
$contact_id = (int) $tree->getPreference('CONTACT_USER_ID');
if ($contact_id !== 0) {
$user = $this->user_service->find($contact_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
$webmaster_id = (int) \Fisharebest\Webtrees\Site::getPreference('WEBMASTER_USER_ID');
if ($webmaster_id !== 0) {
$user = $this->user_service->find($webmaster_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
// Last resort: derive a sender address from the server hostname.
// The mail transport must accept this; admins will normally have
// a contact user set, so this only matters for fresh installs.
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$address = 'no-reply@' . preg_replace('/[^a-zA-Z0-9.\-]/', '', $host);
return new ExtraRecipient($address, self::FROM_NAME);
}
/**
* Format the issue date for the email subject in the recipient's
* locale: "15. Mai 2026" for de, "May 15, 2026" for en-US. Falls
* back to the English date() format if ext-intl is unavailable —
* webtrees lists intl as required so this should not happen in
* practice, but we don't want to fatal a dispatch over it.
*/
private function formatSubjectDate(int $timestamp, string $language): string
{
if (class_exists(IntlDateFormatter::class)) {
$formatter = IntlDateFormatter::create(
str_replace('-', '_', $language),
IntlDateFormatter::LONG,
IntlDateFormatter::NONE,
);
if ($formatter instanceof IntlDateFormatter) {
$formatted = $formatter->format($timestamp);
if (is_string($formatted) && $formatted !== '') {
return $formatted;
}
}
}
return date('F j, Y', $timestamp);
}
/**
* The intro paragraph is signed by the tree contact user — the
* same person webtrees uses for replies. If they have a linked
* Individual record on this tree, return it so the email view
* can render their avatar beside the intro; otherwise return
* null and the view falls back to a single-column layout.
*/
private function resolveIntroAuthor(Tree $tree, UserInterface $author): Individual|null
{
if (!$author instanceof User) {
return null;
}
$xref = $tree->getUserPreference($author, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($xref === '') {
return null;
}
$individual = Registry::individualFactory()->make($xref, $tree);
return $individual instanceof Individual ? $individual : null;
}
/**
* Replace `{{first_name}}`, `{{last_name}}`, `{{username}}` and
* `{{email}}` placeholders in the admin-supplied intro with values
* from the current recipient.
*
* Webtrees users only have a single `realName()` field; we split
* on the first whitespace run to derive first/last. External
* (non-user) recipients fall through with their email in place of
* a name — they have no username, so `{{username}}` resolves to
* an empty string for them.
*/
private function renderIntroTemplate(string $intro, UserInterface $recipient): string
{
if ($intro === '' || !str_contains($intro, '{{')) {
return $intro;
}
$real_name = trim($recipient->realName());
$parts = $real_name === '' ? [] : preg_split('/\s+/', $real_name, 2);
$first = $parts[0] ?? '';
$last = $parts[1] ?? '';
return strtr($intro, [
'{{first_name}}' => $first,
'{{last_name}}' => $last,
'{{username}}' => $recipient->userName(),
'{{email}}' => $recipient->email(),
]);
}
private function htmlToText(string $html): string
{
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
$without_tags = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $without_tags) ?? $without_tags;
$stripped = strip_tags($without_tags);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Collapse runs of blank lines.
return trim(preg_replace("/\n{3,}/", "\n\n", $decoded) ?? $decoded);
}
}