From 9ccc636105350db50c95882f4dc228819d2a26e0 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 14:30:01 +0200 Subject: [PATCH] Admin user roster; per-locale subject; SiteUser as From - Admin preferences page can now subscribe existing webtrees users per tree, not just external addresses. - Subject prefix is now configurable per locale (en/de), and the date in the subject is formatted via IntlDateFormatter in the recipient's locale. - "From:" header now uses SiteUser (SMTP_FROM_NAME/SMTP_DISP_NAME) to match webtrees' own system-mail convention; the tree contact becomes the Reply-To. --- resources/views/admin.phtml | 81 ++++++++++++++++++++-- src/Configuration.php | 60 ++++++++++++++++ src/Module.php | 57 ++++++++++++++- src/Services/NewsletterDispatchService.php | 71 +++++++++++++++++-- 4 files changed, 253 insertions(+), 16 deletions(-) diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml index fe4e5ec..4ff126f 100644 --- a/resources/views/admin.phtml +++ b/resources/views/admin.phtml @@ -8,11 +8,13 @@ use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel; use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Tree; +use Fisharebest\Webtrees\User; use Illuminate\Support\Collection; /** * @var Module $module * @var Collection $all_trees + * @var Collection $all_users * @var string $cron_token * @var string $cron_url * @var string $title @@ -117,16 +119,43 @@ use Illuminate\Support\Collection; -
-
+
+
+ +
+ + isEmpty()) : ?> + + + + + + + +
+ + id(); + $is_subbed = $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1'; + ?> +
+ > + +
+ +
+ +
+
+ 0) : ?>
diff --git a/src/Configuration.php b/src/Configuration.php index fe3fb33..bf39f7f 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -24,6 +24,26 @@ final class Configuration public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH'; public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT'; public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX'; + public const string PREF_SUBJECT_PREFIX_PREFIX = 'NEWSLETTER_SUBJ_PFX_'; + + /** + * Languages we render newsletters in. The dispatch service groups + * recipients by these short codes (German for users whose webtrees + * language starts with "de", English for everyone else), so the + * admin only has to set a subject prefix for these two locales. + * + * Keep these as short two-letter codes so the per-locale storage + * keys stay well below the webtrees 32-char setting_name limit. + * + * @return array short-code => display label + */ + public static function supportedSubjectLocales(): array + { + return [ + 'en' => 'English', + 'de' => 'Deutsch', + ]; + } // Module-level (not tree-bound) settings. public const string MODULE_PREF_CRON_TOKEN = 'cron_token'; @@ -120,6 +140,46 @@ final class Configuration return $tree->getPreference(self::PREF_SUBJECT_PREFIX, '[' . $tree->title() . '] '); } + /** + * Normalises a webtrees language tag (e.g. "de", "de-DE", "en-US") + * to one of the short codes we offer in the admin UI. + */ + public static function canonicalSubjectLocale(string $language): string + { + $lower = strtolower($language); + + foreach (array_keys(self::supportedSubjectLocales()) as $code) { + if (str_starts_with($lower, $code)) { + return $code; + } + } + + return 'en'; + } + + /** + * Per-locale subject prefix. Falls back to the generic + * (locale-agnostic) prefix, then to "[Tree Title] " so admins + * who never opened the form still get something sensible. + */ + public static function subjectPrefixForLocale(Tree $tree, string $language): string + { + $code = self::canonicalSubjectLocale($language); + $specific = $tree->getPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, ''); + + if ($specific !== '') { + return $specific; + } + + return self::subjectPrefix($tree); + } + + public static function setSubjectPrefixForLocale(Tree $tree, string $language, string $prefix): void + { + $code = self::canonicalSubjectLocale($language); + $tree->setPreference(self::PREF_SUBJECT_PREFIX_PREFIX . $code, $prefix); + } + /** * Extra recipient email addresses configured by the admin (one per line). * diff --git a/src/Module.php b/src/Module.php index 7121ae4..8a9b6e5 100644 --- a/src/Module.php +++ b/src/Module.php @@ -28,6 +28,8 @@ use Fisharebest\Webtrees\Module\ModuleCustomTrait; use Fisharebest\Webtrees\Module\ModuleMenuInterface; use Fisharebest\Webtrees\Module\ModuleMenuTrait; use Fisharebest\Webtrees\Services\TreeService; +use Fisharebest\Webtrees\Services\UserService; +use Fisharebest\Webtrees\User; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Validator; use Fisharebest\Webtrees\View; @@ -45,6 +47,7 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf public function __construct( private readonly NewsletterDispatchService $dispatch_service, private readonly TreeService $tree_service, + private readonly UserService $user_service, ) { } @@ -160,6 +163,13 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf => 'Richten Sie System-Cron, systemd-Timer oder einen externen Scheduler so ein, dass er die untenstehende URL aufruft. Der Versandplan entscheidet, wann tatsächlich gesendet wird — häufiger aufrufen ist unbedenklich.', 'Send the newsletter now for every enabled tree?' => 'Newsletter jetzt für jeden aktivierten Baum senden?', + 'Subscribed users' => 'Abonnierte Nutzer', + 'No users with email addresses found.' => 'Keine Nutzer mit E-Mail-Adresse gefunden.', + 'Tick a user to subscribe them to this tree’s newsletter. Users can still adjust their own subscription on their account page.' + => 'Setzen Sie einen Haken, um den Nutzer für den Newsletter dieses Stammbaums zu abonnieren. Nutzer können ihr Abonnement weiterhin selbst auf ihrer Kontoseite anpassen.', + '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', ], 'nl' => [ 'Email Newsletter' => 'E-mailnieuwsbrief', @@ -219,10 +229,20 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf { $this->layout = 'layouts/administration'; + // Surface every webtrees user with an email so the admin can + // toggle subscription per tree without having to ask each + // member to opt in themselves. Sorted alphabetically by real + // name so the list stays scannable in long member rosters. + $users = $this->user_service->all() + ->filter(static fn (User $user): bool => trim($user->email()) !== '') + ->sortBy(static fn (User $user): string => mb_strtolower($user->realName())) + ->values(); + return $this->viewResponse($this->name() . '::admin', [ 'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'), 'module' => $this, 'all_trees' => $this->tree_service->all(), + 'all_users' => $users, 'cron_token' => $this->cronToken(), 'cron_url' => $this->cronUrl(), ]); @@ -230,6 +250,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf public function postAdminAction(ServerRequestInterface $request): ResponseInterface { + // Cache the user list so we don't query it once per tree. + $users = $this->user_service->all() + ->filter(static fn (User $user): bool => trim($user->email()) !== ''); + foreach ($this->tree_service->all() as $tree) { $id = $tree->id(); $enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1'; @@ -249,8 +273,37 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf $tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0'); $tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras); - if ($subject !== '') { - $tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject); + // Generic prefix — used when no per-locale override is set. + // We always write it (even empty) so admins can clear a + // previously-saved value. + $tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject); + + foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) { + $locale_prefix = Validator::parsedBody($request) + ->string('subject-' . $id . '-' . $code, ''); + + Configuration::setSubjectPrefixForLocale($tree, $code, $locale_prefix); + } + + // Per-user subscription toggles. A users-roster marker is + // always submitted (hidden field "users-submitted-") + // so we can tell an unchecked-everyone POST apart from a + // legacy form that omits the section entirely — we only + // touch subscriptions when the marker is present. + $roster_present = Validator::parsedBody($request) + ->string('users-submitted-' . $id, '0') === '1'; + + if ($roster_present) { + foreach ($users as $user) { + $field = 'subscribe-' . $id . '-' . $user->id(); + $subscribed = Validator::parsedBody($request)->string($field, '0') === '1'; + + $tree->setUserPreference( + $user, + Configuration::USER_PREF_SUBSCRIBED, + $subscribed ? '1' : '0', + ); + } } } diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index e62d483..c8b4273 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -15,6 +15,8 @@ use Fisharebest\Webtrees\Module\ModuleInterface; 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; @@ -22,6 +24,7 @@ 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; @@ -129,8 +132,17 @@ final class NewsletterDispatchService return sprintf('Tree "%s": no subscribers, skipped.', $tree->name()); } - $from = $this->siteContact($tree); - $original_locale = I18N::languageTag(); + // 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); $avatar_cids = $this->avatarCids($avatars); @@ -147,10 +159,11 @@ final class NewsletterDispatchService foreach ($groups as $lang => $group) { I18N::init($lang); - $subject = Configuration::subjectPrefix($tree) . I18N::translate( - 'Family newsletter — %s', - date('F j, Y', $now), - ); + $subject = Configuration::subjectPrefixForLocale($tree, $lang) + . I18N::translate( + 'Family newsletter — %s', + $this->formatSubjectDate($now, $lang), + ); // Render the email body per recipient — the relationship // labels are personalised relative to whichever individual @@ -188,7 +201,7 @@ final class NewsletterDispatchService $text = $this->htmlToText($html); try { - if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $subject, $text, $html, $recipient_avatars)) { + 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- @@ -657,6 +670,22 @@ final class NewsletterDispatchService 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. @@ -692,6 +721,34 @@ final class NewsletterDispatchService 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); + } + private function htmlToText(string $html): string { $without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;