One-shot bilingual intro paragraph with markdown + author avatar
- Admin can set a per-locale intro paragraph for the next issue on
the preferences page; cleared automatically after a successful
send. Stored in module_setting (longText) so multi-paragraph
notes fit.
- Intro is rendered via webtrees' CommonMark factory (same flavour
as notes) with raw HTML escaped, supports {{first_name}},
{{last_name}}, {{username}}, {{email}} substitution per recipient.
- Two-column intro layout: tree contact user's linked Individual
becomes the editorial portrait on the left. Their avatar is
added to the per-recipient embed set so the inline image always
resolves rather than falling through to a tree-page login link.
- Masthead now shows the tree URL under the title.
- Avatar source dimensions bumped 96→192 px and JPEG quality 75→88
so portraits stay crisp at retina display ratios.
This commit is contained in:
@@ -167,6 +167,38 @@ use Illuminate\Support\Collection;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="row mb-3">
|
||||||
|
<legend class="col-sm-3 col-form-label">
|
||||||
|
<?= I18N::translate('Intro paragraph for the next email') ?>
|
||||||
|
</legend>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<small class="form-text text-muted d-block mb-2">
|
||||||
|
<?= I18N::translate('Shown once, above the upcoming events. Cleared automatically after the next successful send.') ?>
|
||||||
|
<br>
|
||||||
|
<?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?>
|
||||||
|
<br>
|
||||||
|
<?= I18N::translate('Personalisation tokens:') ?>
|
||||||
|
<code>{{first_name}}</code>,
|
||||||
|
<code>{{last_name}}</code>,
|
||||||
|
<code>{{username}}</code>,
|
||||||
|
<code>{{email}}</code>
|
||||||
|
</small>
|
||||||
|
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
|
||||||
|
<?php
|
||||||
|
$field = 'intro-' . $id . '-' . $code;
|
||||||
|
$val = Configuration::introForLocale($module, $tree, $code);
|
||||||
|
?>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small text-muted mb-1" for="<?= e($field) ?>">
|
||||||
|
<?= e($label) ?>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" rows="6"
|
||||||
|
id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label class="col-sm-3 col-form-label">
|
<label class="col-sm-3 col-form-label">
|
||||||
<?= I18N::translate('Subscribed users') ?>
|
<?= I18N::translate('Subscribed users') ?>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
use Fisharebest\Webtrees\Date;
|
use Fisharebest\Webtrees\Date;
|
||||||
use Fisharebest\Webtrees\Fact;
|
use Fisharebest\Webtrees\Fact;
|
||||||
use Fisharebest\Webtrees\Family;
|
use Fisharebest\Webtrees\Family;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\TreePage;
|
||||||
use Fisharebest\Webtrees\I18N;
|
use Fisharebest\Webtrees\I18N;
|
||||||
use Fisharebest\Webtrees\Individual;
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
use Fisharebest\Webtrees\Tree;
|
use Fisharebest\Webtrees\Tree;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ use Illuminate\Support\Collection;
|
|||||||
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
|
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
|
||||||
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
|
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
|
||||||
* @var string $account_url
|
* @var string $account_url
|
||||||
|
* @var string $intro Admin-supplied one-shot intro paragraph; "" = skip block
|
||||||
|
* @var Individual|null $intro_author Tree contact's linked record, if any — avatar source for the intro
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── BockenTheme light-mode palette ─────────────────────────────────────
|
// ─── BockenTheme light-mode palette ─────────────────────────────────────
|
||||||
@@ -592,7 +596,20 @@ $timeline_arrow_row = '<tr>'
|
|||||||
<h1 style="margin:10px 0 6px;font-weight:300;font-size:38px;line-height:1.1;letter-spacing:-0.015em;color:<?= $palette['ink'] ?>;">
|
<h1 style="margin:10px 0 6px;font-weight:300;font-size:38px;line-height:1.1;letter-spacing:-0.015em;color:<?= $palette['ink'] ?>;">
|
||||||
<?= e($tree->title()) ?>
|
<?= e($tree->title()) ?>
|
||||||
</h1>
|
</h1>
|
||||||
<div style="font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
|
<?php
|
||||||
|
// Strip the leading scheme so the link
|
||||||
|
// reads as a clean hostname/path — the
|
||||||
|
// anchor still points at the absolute URL.
|
||||||
|
$tree_url = route(TreePage::class, ['tree' => $tree->name()]);
|
||||||
|
$tree_url_lbl = preg_replace('~^https?://~i', '', rtrim($tree_url, '/'));
|
||||||
|
?>
|
||||||
|
<div style="margin-top:4px;font-size:13px;font-weight:400;letter-spacing:0.01em;">
|
||||||
|
<a href="<?= e($tree_url) ?>"
|
||||||
|
style="color:<?= $palette['link'] ?>;text-decoration:none;border-bottom:1px solid <?= $palette['link'] ?>33;">
|
||||||
|
<?= e($tree_url_lbl) ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:13px;font-weight:300;color:<?= $palette['ink3'] ?>;">
|
||||||
<?= e($masthead_date($generated_at)) ?>
|
<?= e($masthead_date($generated_at)) ?>
|
||||||
<span style="color:<?= $palette['mute'] ?>;">·</span>
|
<span style="color:<?= $palette['mute'] ?>;">·</span>
|
||||||
<?= e(I18N::translate('Events in the next %d days.', $window_days)) ?>
|
<?= e(I18N::translate('Events in the next %d days.', $window_days)) ?>
|
||||||
@@ -600,6 +617,40 @@ $timeline_arrow_row = '<tr>'
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<?php if (trim($intro) !== '') : ?>
|
||||||
|
<!-- Editorial: one-shot intro paragraph ──────────── -->
|
||||||
|
<?php
|
||||||
|
// Render via webtrees' Markdown factory: CommonMark
|
||||||
|
// with autolinks, the same flavour used elsewhere
|
||||||
|
// in the site. Raw HTML in the source is escaped
|
||||||
|
// by the factory's HtmlFilter::ESCAPE setting, so
|
||||||
|
// a stray "<" can't break the email layout.
|
||||||
|
$intro_html = Registry::markdownFactory()->markdown(trim($intro), $tree);
|
||||||
|
$intro_inner = '<div style="border-left:3px solid ' . $palette['accent'] . ';padding:6px 0 6px 16px;'
|
||||||
|
. 'font-size:15px;line-height:1.55;font-weight:300;color:' . $palette['ink'] . ';'
|
||||||
|
. 'font-style:italic;">' . $intro_html . '</div>';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 8px 24px;">
|
||||||
|
<?php if ($intro_author instanceof Individual) : ?>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="width:72px;vertical-align:top;padding-top:4px;">
|
||||||
|
<?= $avatar($intro_author) ?>
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:top;">
|
||||||
|
<?= $intro_inner ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php else : ?>
|
||||||
|
<?= $intro_inner ?>
|
||||||
|
<?php endif ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<?php if (!$birthdays->isEmpty()) : ?>
|
<?php if (!$birthdays->isEmpty()) : ?>
|
||||||
<?php
|
<?php
|
||||||
$detailed = [];
|
$detailed = [];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace EmailNewsletter;
|
namespace EmailNewsletter;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||||
use Fisharebest\Webtrees\Tree;
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +27,20 @@ final class Configuration
|
|||||||
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
|
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
|
||||||
public const string PREF_SUBJECT_PREFIX_PREFIX = 'NEWSLETTER_SUBJ_PFX_';
|
public const string PREF_SUBJECT_PREFIX_PREFIX = 'NEWSLETTER_SUBJ_PFX_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-locale one-shot intro paragraph. Cleared automatically the
|
||||||
|
* first time the dispatch service successfully sends an issue
|
||||||
|
* containing it, so each setting is "for the next email only".
|
||||||
|
*
|
||||||
|
* Stored in webtrees' module_setting table (longText column) so
|
||||||
|
* we can hold multi-paragraph intros — gedcom_setting.setting_value
|
||||||
|
* is varchar(255) and would truncate anything beyond a sentence
|
||||||
|
* or two. The full preference name is
|
||||||
|
* intro_{tree_id}_{locale-code}
|
||||||
|
* which stays well within the 32-char setting_name limit.
|
||||||
|
*/
|
||||||
|
public const string MODULE_PREF_INTRO_PREFIX = 'intro_';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Languages we render newsletters in. The dispatch service groups
|
* Languages we render newsletters in. The dispatch service groups
|
||||||
* recipients by these short codes (German for users whose webtrees
|
* recipients by these short codes (German for users whose webtrees
|
||||||
@@ -180,6 +195,43 @@ final class Configuration
|
|||||||
$tree->setPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, $prefix);
|
$tree->setPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, $prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot intro paragraph for the next issue, in the recipient's
|
||||||
|
* locale. Empty string means "no intro" — the email view skips
|
||||||
|
* the block entirely.
|
||||||
|
*
|
||||||
|
* Stored on the module (longText) rather than the tree (varchar(255))
|
||||||
|
* so a real paragraph fits.
|
||||||
|
*/
|
||||||
|
public static function introForLocale(AbstractModule $module, Tree $tree, string $language): string
|
||||||
|
{
|
||||||
|
$code = self::canonicalSubjectLocale($language);
|
||||||
|
|
||||||
|
return $module->getPreference(self::introKey($tree, $code), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setIntroForLocale(AbstractModule $module, Tree $tree, string $language, string $intro): void
|
||||||
|
{
|
||||||
|
$code = self::canonicalSubjectLocale($language);
|
||||||
|
$module->setPreference(self::introKey($tree, $code), $intro);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe every locale's intro paragraph. Called by the dispatcher
|
||||||
|
* after a successful send so the next issue starts clean.
|
||||||
|
*/
|
||||||
|
public static function clearIntros(AbstractModule $module, Tree $tree): void
|
||||||
|
{
|
||||||
|
foreach (array_keys(self::supportedSubjectLocales()) as $code) {
|
||||||
|
$module->setPreference(self::introKey($tree, $code), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function introKey(Tree $tree, string $locale_code): string
|
||||||
|
{
|
||||||
|
return self::MODULE_PREF_INTRO_PREFIX . $tree->id() . '_' . $locale_code;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extra recipient email addresses configured by the admin (one per line).
|
* Extra recipient email addresses configured by the admin (one per line).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
'Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.'
|
'Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.'
|
||||||
=> 'Wird der E-Mail-Betreffzeile vorangestellt. Ein leeres Feld greift auf das generische Präfix unten zurück.',
|
=> 'Wird der E-Mail-Betreffzeile vorangestellt. Ein leeres Feld greift auf das generische Präfix unten zurück.',
|
||||||
'Generic' => 'Allgemein',
|
'Generic' => 'Allgemein',
|
||||||
|
'Intro paragraph for the next email' => 'Einleitungsabsatz für die nächste E-Mail',
|
||||||
|
'Shown once, above the upcoming events. Cleared automatically after the next successful send.'
|
||||||
|
=> 'Wird einmalig über den anstehenden Ereignissen angezeigt. Wird nach dem nächsten erfolgreichen Versand automatisch geleert.',
|
||||||
|
'Personalisation tokens:' => 'Personalisierungs-Platzhalter:',
|
||||||
|
'Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.'
|
||||||
|
=> 'Formatiert als Markdown — z. B. %1$s für Hervorhebung, %2$s für einen Link.',
|
||||||
],
|
],
|
||||||
'nl' => [
|
'nl' => [
|
||||||
'Email Newsletter' => 'E-mailnieuwsbrief',
|
'Email Newsletter' => 'E-mailnieuwsbrief',
|
||||||
@@ -283,6 +289,11 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
|
|||||||
->string('subject-' . $id . '-' . $code, '');
|
->string('subject-' . $id . '-' . $code, '');
|
||||||
|
|
||||||
Configuration::setSubjectPrefixForLocale($tree, $code, $locale_prefix);
|
Configuration::setSubjectPrefixForLocale($tree, $code, $locale_prefix);
|
||||||
|
|
||||||
|
$intro = Validator::parsedBody($request)
|
||||||
|
->string('intro-' . $id . '-' . $code, '');
|
||||||
|
|
||||||
|
Configuration::setIntroForLocale($this, $tree, $code, $intro);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user subscription toggles. A users-roster marker is
|
// Per-user subscription toggles. A users-roster marker is
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Fisharebest\Webtrees\Family;
|
|||||||
use Fisharebest\Webtrees\I18N;
|
use Fisharebest\Webtrees\I18N;
|
||||||
use Fisharebest\Webtrees\Individual;
|
use Fisharebest\Webtrees\Individual;
|
||||||
use Fisharebest\Webtrees\Log;
|
use Fisharebest\Webtrees\Log;
|
||||||
use Fisharebest\Webtrees\Module\ModuleInterface;
|
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||||
use Fisharebest\Webtrees\Registry;
|
use Fisharebest\Webtrees\Registry;
|
||||||
use Fisharebest\Webtrees\Services\TreeService;
|
use Fisharebest\Webtrees\Services\TreeService;
|
||||||
use Fisharebest\Webtrees\Services\UserService;
|
use Fisharebest\Webtrees\Services\UserService;
|
||||||
@@ -47,17 +47,19 @@ final class NewsletterDispatchService
|
|||||||
private const string FROM_NAME = 'webtrees newsletter';
|
private const string FROM_NAME = 'webtrees newsletter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Target dimensions for embedded avatars, in pixels. Rendered at 48
|
* Target dimensions for embedded avatars, in pixels. Rendered at
|
||||||
* CSS pixels, so 96 covers HiDPI displays. Larger source images are
|
* 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.
|
* cover-cropped down; smaller ones are left untouched.
|
||||||
*/
|
*/
|
||||||
private const int AVATAR_SIZE = 96;
|
private const int AVATAR_SIZE = 192;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPEG quality used when re-encoding resized avatars. 75 is a good
|
* JPEG quality used when re-encoding resized avatars. 88 keeps
|
||||||
* size/quality trade-off for small portraits.
|
* portraits crisp without ballooning the email size — a 192px
|
||||||
|
* face encodes to ~25–40 KB at this quality.
|
||||||
*/
|
*/
|
||||||
private const int AVATAR_JPEG_QUALITY = 75;
|
private const int AVATAR_JPEG_QUALITY = 88;
|
||||||
|
|
||||||
private ImageManager|null $image_manager = null;
|
private ImageManager|null $image_manager = null;
|
||||||
private bool $image_manager_resolved = false;
|
private bool $image_manager_resolved = false;
|
||||||
@@ -78,7 +80,7 @@ final class NewsletterDispatchService
|
|||||||
* happened, suitable for the cron endpoint
|
* happened, suitable for the cron endpoint
|
||||||
* to return to the caller.
|
* to return to the caller.
|
||||||
*/
|
*/
|
||||||
public function dispatch(ModuleInterface $module, bool $force = false): array
|
public function dispatch(AbstractModule $module, bool $force = false): array
|
||||||
{
|
{
|
||||||
$log = [];
|
$log = [];
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -98,7 +100,7 @@ final class NewsletterDispatchService
|
|||||||
return $log;
|
return $log;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now, bool $force): string
|
private function dispatchForTree(Tree $tree, AbstractModule $module, int $now, bool $force): string
|
||||||
{
|
{
|
||||||
$include_anniversaries = Configuration::includeAnniversaries($tree);
|
$include_anniversaries = Configuration::includeAnniversaries($tree);
|
||||||
|
|
||||||
@@ -145,8 +147,24 @@ final class NewsletterDispatchService
|
|||||||
$original_locale = I18N::languageTag();
|
$original_locale = I18N::languageTag();
|
||||||
$groups = $this->groupRecipientsByLanguage($recipients);
|
$groups = $this->groupRecipientsByLanguage($recipients);
|
||||||
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
|
||||||
$avatar_cids = $this->avatarCids($avatars);
|
|
||||||
$featured = $this->collectFeaturedIndividuals($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,
|
||||||
|
// matching the styling of the event cards below.
|
||||||
|
$intro_author = $this->resolveIntroAuthor($tree, $reply_to);
|
||||||
|
|
||||||
|
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(
|
$account_url = route(
|
||||||
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
|
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
|
||||||
['tree' => $tree->name()],
|
['tree' => $tree->name()],
|
||||||
@@ -165,6 +183,12 @@ final class NewsletterDispatchService
|
|||||||
$this->formatSubjectDate($now, $lang),
|
$this->formatSubjectDate($now, $lang),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One-shot intro paragraph (admin-supplied). Empty
|
||||||
|
// string is "no intro" — the view simply omits the
|
||||||
|
// block. Cleared from preferences after the run if
|
||||||
|
// at least one recipient was successfully reached.
|
||||||
|
$intro = Configuration::introForLocale($module, $tree, $lang);
|
||||||
|
|
||||||
// Render the email body per recipient — the relationship
|
// Render the email body per recipient — the relationship
|
||||||
// labels are personalised relative to whichever individual
|
// labels are personalised relative to whichever individual
|
||||||
// record the recipient is linked to in this tree.
|
// record the recipient is linked to in this tree.
|
||||||
@@ -184,6 +208,19 @@ final class NewsletterDispatchService
|
|||||||
$this->avatarKeysForXrefs(array_keys($detailed_set)),
|
$this->avatarKeysForXrefs(array_keys($detailed_set)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The intro author sits outside the detailed set —
|
||||||
|
// make sure their avatar bytes still ride along so
|
||||||
|
// the editorial portrait renders inline instead of
|
||||||
|
// showing a broken image (which we'd otherwise hide
|
||||||
|
// behind a tree-page login link).
|
||||||
|
if ($intro_author instanceof Individual) {
|
||||||
|
$author_cid = $this->avatarCidName($intro_author->xref());
|
||||||
|
|
||||||
|
if (isset($avatars[$author_cid])) {
|
||||||
|
$recipient_avatars[$author_cid] = $avatars[$author_cid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$html = view($module->name() . '::email', [
|
$html = view($module->name() . '::email', [
|
||||||
'tree' => $tree,
|
'tree' => $tree,
|
||||||
'birthdays' => $birthdays,
|
'birthdays' => $birthdays,
|
||||||
@@ -196,6 +233,8 @@ final class NewsletterDispatchService
|
|||||||
'relationships' => $relationships,
|
'relationships' => $relationships,
|
||||||
'detailed_xrefs' => $detailed_set,
|
'detailed_xrefs' => $detailed_set,
|
||||||
'account_url' => $account_url,
|
'account_url' => $account_url,
|
||||||
|
'intro' => $this->renderIntroTemplate($intro, $recipient),
|
||||||
|
'intro_author' => $intro_author,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$text = $this->htmlToText($html);
|
$text = $this->htmlToText($html);
|
||||||
@@ -230,6 +269,14 @@ final class NewsletterDispatchService
|
|||||||
Configuration::setLastSentAt($tree, $now);
|
Configuration::setLastSentAt($tree, $now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot intro: clear only once at least one recipient was
|
||||||
|
// actually reached. A run that produced nothing but failures
|
||||||
|
// (transport down, all addresses bouncing) preserves the
|
||||||
|
// admin's intro for the next attempt.
|
||||||
|
if ($sent > 0) {
|
||||||
|
Configuration::clearIntros($module, $tree);
|
||||||
|
}
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'Tree "%s": sent to %d recipient(s), %d failure(s).',
|
'Tree "%s": sent to %d recipient(s), %d failure(s).',
|
||||||
$tree->name(),
|
$tree->name(),
|
||||||
@@ -749,6 +796,60 @@ final class NewsletterDispatchService
|
|||||||
return date('F j, Y', $timestamp);
|
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
|
private function htmlToText(string $html): string
|
||||||
{
|
{
|
||||||
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
|
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
|
||||||
|
|||||||
Reference in New Issue
Block a user