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:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace EmailNewsletter;
|
||||
|
||||
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||
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_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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user