+ $tree->name()]);
+ $tree_url_lbl = preg_replace('~^https?://~i', '', rtrim($tree_url, '/'));
+ ?>
+
= e($masthead_date($generated_at)) ?>
·
= e(I18N::translate('Events in the next %d days.', $window_days)) ?>
@@ -600,6 +617,40 @@ $timeline_arrow_row = '
'
+
+
+ markdown(trim($intro), $tree);
+ $intro_inner = '
' . $intro_html . '
';
+ ?>
+
+
+
+
+
+ |
+ = $avatar($intro_author) ?>
+ |
+
+ = $intro_inner ?>
+ |
+
+
+
+ = $intro_inner ?>
+
+ |
+
+
+
isEmpty()) : ?>
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).
*
diff --git a/src/Module.php b/src/Module.php
index 8a9b6e5..f465dd2 100644
--- a/src/Module.php
+++ b/src/Module.php
@@ -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.'
=> 'Wird der E-Mail-Betreffzeile vorangestellt. Ein leeres Feld greift auf das generische Präfix unten zurück.',
'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' => [
'Email Newsletter' => 'E-mailnieuwsbrief',
@@ -283,6 +289,11 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
->string('subject-' . $id . '-' . $code, '');
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
diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php
index c8b4273..d30ce57 100644
--- a/src/Services/NewsletterDispatchService.php
+++ b/src/Services/NewsletterDispatchService.php
@@ -11,7 +11,7 @@ use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Log;
-use Fisharebest\Webtrees\Module\ModuleInterface;
+use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
@@ -47,17 +47,19 @@ final class NewsletterDispatchService
private const string FROM_NAME = 'webtrees newsletter';
/**
- * Target dimensions for embedded avatars, in pixels. Rendered at 48
- * CSS pixels, so 96 covers HiDPI displays. Larger source images are
+ * 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 = 96;
+ private const int AVATAR_SIZE = 192;
/**
- * JPEG quality used when re-encoding resized avatars. 75 is a good
- * size/quality trade-off for small portraits.
+ * JPEG quality used when re-encoding resized avatars. 88 keeps
+ * 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 bool $image_manager_resolved = false;
@@ -78,7 +80,7 @@ final class NewsletterDispatchService
* happened, suitable for the cron endpoint
* to return to the caller.
*/
- public function dispatch(ModuleInterface $module, bool $force = false): array
+ public function dispatch(AbstractModule $module, bool $force = false): array
{
$log = [];
$now = time();
@@ -98,7 +100,7 @@ final class NewsletterDispatchService
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);
@@ -145,8 +147,24 @@ final class NewsletterDispatchService
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
- $avatar_cids = $this->avatarCids($avatars);
$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(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
@@ -165,6 +183,12 @@ final class NewsletterDispatchService
$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
// labels are personalised relative to whichever individual
// record the recipient is linked to in this tree.
@@ -184,6 +208,19 @@ final class NewsletterDispatchService
$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', [
'tree' => $tree,
'birthdays' => $birthdays,
@@ -196,6 +233,8 @@ final class NewsletterDispatchService
'relationships' => $relationships,
'detailed_xrefs' => $detailed_set,
'account_url' => $account_url,
+ 'intro' => $this->renderIntroTemplate($intro, $recipient),
+ 'intro_author' => $intro_author,
]);
$text = $this->htmlToText($html);
@@ -230,6 +269,14 @@ final class NewsletterDispatchService
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(
'Tree "%s": sent to %d recipient(s), %d failure(s).',
$tree->name(),
@@ -749,6 +796,60 @@ final class NewsletterDispatchService
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;