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.
This commit is contained in:
2026-05-15 14:30:01 +02:00
parent 00478e2466
commit 9ccc636105
4 changed files with 253 additions and 16 deletions
+74 -7
View File
@@ -8,11 +8,13 @@ use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage; use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage;
use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* @var Module $module * @var Module $module
* @var Collection<int,Tree> $all_trees * @var Collection<int,Tree> $all_trees
* @var Collection<int,User> $all_users
* @var string $cron_token * @var string $cron_token
* @var string $cron_url * @var string $cron_url
* @var string $title * @var string $title
@@ -117,16 +119,43 @@ use Illuminate\Support\Collection;
</div> </div>
</div> </div>
<div class="row mb-3"> <fieldset class="row mb-3">
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>"> <legend class="col-sm-3 col-form-label">
<?= I18N::translate('Subject prefix') ?> <?= I18N::translate('Subject prefix') ?>
</label> </legend>
<div class="col-sm-9"> <div class="col-sm-9">
<input class="form-control" type="text" <small class="form-text text-muted d-block mb-2">
id="subject-<?= $id ?>" name="subject-<?= $id ?>" <?= I18N::translate('Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.') ?>
value="<?= e($subject) ?>"> </small>
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
<?php
$field = 'subject-' . $id . '-' . $code;
$val = $tree->getPreference(
Configuration::PREF_SUBJECT_PREFIX_PREFIX . $code,
'',
);
?>
<div class="input-group input-group-sm mb-2">
<span class="input-group-text" style="min-width: 7rem;">
<?= e($label) ?>
</span>
<input class="form-control" type="text"
id="<?= e($field) ?>" name="<?= e($field) ?>"
value="<?= e($val) ?>"
placeholder="<?= e('[' . $tree->title() . '] ') ?>">
</div>
<?php endforeach ?>
<div class="input-group input-group-sm">
<span class="input-group-text" style="min-width: 7rem;">
<?= I18N::translate('Generic') ?>
</span>
<input class="form-control" type="text"
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
value="<?= e($subject) ?>">
</div>
</div> </div>
</div> </fieldset>
<div class="row mb-3"> <div class="row mb-3">
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>"> <label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
@@ -138,6 +167,44 @@ use Illuminate\Support\Collection;
</div> </div>
</div> </div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">
<?= I18N::translate('Subscribed users') ?>
</label>
<div class="col-sm-9">
<input type="hidden" name="users-submitted-<?= $id ?>" value="1">
<?php if ($all_users->isEmpty()) : ?>
<small class="form-text text-muted">
<?= I18N::translate('No users with email addresses found.') ?>
</small>
<?php else : ?>
<small class="form-text text-muted d-block mb-2">
<?= I18N::translate('Tick a user to subscribe them to this trees newsletter. Users can still adjust their own subscription on their account page.') ?>
</small>
<div class="border rounded p-2"
style="max-height: 18rem; overflow-y: auto;">
<?php foreach ($all_users as $user) : ?>
<?php
$field = 'subscribe-' . $id . '-' . $user->id();
$is_subbed = $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
?>
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="<?= e($field) ?>" name="<?= e($field) ?>"
value="1" <?= $is_subbed ? 'checked' : '' ?>>
<label class="form-check-label" for="<?= e($field) ?>">
<?= e($user->realName()) ?>
<small class="text-muted">
&lt;<?= e($user->email()) ?>&gt;
</small>
</label>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
</div>
<?php if ($last_sent > 0) : ?> <?php if ($last_sent > 0) : ?>
<div class="row mb-3"> <div class="row mb-3">
<div class="offset-sm-3 col-sm-9"> <div class="offset-sm-3 col-sm-9">
+60
View File
@@ -24,6 +24,26 @@ final class Configuration
public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH'; public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH';
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT'; 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 = '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<string,string> short-code => display label
*/
public static function supportedSubjectLocales(): array
{
return [
'en' => 'English',
'de' => 'Deutsch',
];
}
// Module-level (not tree-bound) settings. // Module-level (not tree-bound) settings.
public const string MODULE_PREF_CRON_TOKEN = 'cron_token'; 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() . '] '); 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). * Extra recipient email addresses configured by the admin (one per line).
* *
+55 -2
View File
@@ -28,6 +28,8 @@ use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Module\ModuleMenuInterface; use Fisharebest\Webtrees\Module\ModuleMenuInterface;
use Fisharebest\Webtrees\Module\ModuleMenuTrait; use Fisharebest\Webtrees\Module\ModuleMenuTrait;
use Fisharebest\Webtrees\Services\TreeService; use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\User;
use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator; use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\View; use Fisharebest\Webtrees\View;
@@ -45,6 +47,7 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
public function __construct( public function __construct(
private readonly NewsletterDispatchService $dispatch_service, private readonly NewsletterDispatchService $dispatch_service,
private readonly TreeService $tree_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.', => '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?' 'Send the newsletter now for every enabled tree?'
=> 'Newsletter jetzt für jeden aktivierten Baum senden?', => '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 trees 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' => [ 'nl' => [
'Email Newsletter' => 'E-mailnieuwsbrief', 'Email Newsletter' => 'E-mailnieuwsbrief',
@@ -219,10 +229,20 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
{ {
$this->layout = 'layouts/administration'; $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', [ return $this->viewResponse($this->name() . '::admin', [
'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'), 'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'),
'module' => $this, 'module' => $this,
'all_trees' => $this->tree_service->all(), 'all_trees' => $this->tree_service->all(),
'all_users' => $users,
'cron_token' => $this->cronToken(), 'cron_token' => $this->cronToken(),
'cron_url' => $this->cronUrl(), 'cron_url' => $this->cronUrl(),
]); ]);
@@ -230,6 +250,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
public function postAdminAction(ServerRequestInterface $request): ResponseInterface 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) { foreach ($this->tree_service->all() as $tree) {
$id = $tree->id(); $id = $tree->id();
$enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1'; $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_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras); $tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
if ($subject !== '') { // Generic prefix — used when no per-locale override is set.
$tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject); // 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-<id>")
// 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',
);
}
} }
} }
+64 -7
View File
@@ -15,6 +15,8 @@ use Fisharebest\Webtrees\Module\ModuleInterface;
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;
use Fisharebest\Webtrees\Site;
use Fisharebest\Webtrees\SiteUser;
use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User; use Fisharebest\Webtrees\User;
use Illuminate\Support\Collection; 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\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use IntlDateFormatter;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Exception\RfcComplianceException; use Symfony\Component\Mime\Exception\RfcComplianceException;
use Throwable; use Throwable;
@@ -129,8 +132,17 @@ final class NewsletterDispatchService
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name()); return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
} }
$from = $this->siteContact($tree); // Match webtrees' own convention for system-generated email
$original_locale = I18N::languageTag(); // (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); $groups = $this->groupRecipientsByLanguage($recipients);
$avatars = $this->collectAvatars($birthdays, $anniversaries, $historical); $avatars = $this->collectAvatars($birthdays, $anniversaries, $historical);
$avatar_cids = $this->avatarCids($avatars); $avatar_cids = $this->avatarCids($avatars);
@@ -147,10 +159,11 @@ final class NewsletterDispatchService
foreach ($groups as $lang => $group) { foreach ($groups as $lang => $group) {
I18N::init($lang); I18N::init($lang);
$subject = Configuration::subjectPrefix($tree) . I18N::translate( $subject = Configuration::subjectPrefixForLocale($tree, $lang)
'Family newsletter — %s', . I18N::translate(
date('F j, Y', $now), 'Family newsletter — %s',
); $this->formatSubjectDate($now, $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
@@ -188,7 +201,7 @@ final class NewsletterDispatchService
$text = $this->htmlToText($html); $text = $this->htmlToText($html);
try { 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++; $sent++;
// Per-user last-sent is stored only for real // Per-user last-sent is stored only for real
// webtrees users (id > 0). External admin- // webtrees users (id > 0). External admin-
@@ -657,6 +670,22 @@ final class NewsletterDispatchService
return $recipients; 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 * Pick a sender identity. Fall back to a synthetic UserInterface if
* the site has no contact user configured for this tree. * the site has no contact user configured for this tree.
@@ -692,6 +721,34 @@ final class NewsletterDispatchService
return new ExtraRecipient($address, self::FROM_NAME); 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 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;