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:
2026-05-15 15:32:30 +02:00
parent 9ccc636105
commit 9458867d4d
5 changed files with 258 additions and 11 deletions
+32
View File
@@ -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') ?>
+52 -1
View File
@@ -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 = [];
+52
View File
@@ -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).
* *
+11
View File
@@ -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
+111 -10
View File
@@ -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 ~2540 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;