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:
@@ -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<int,Tree> $all_trees
|
||||
* @var Collection<int,User> $all_users
|
||||
* @var string $cron_token
|
||||
* @var string $cron_url
|
||||
* @var string $title
|
||||
@@ -117,16 +119,43 @@ use Illuminate\Support\Collection;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
|
||||
<fieldset class="row mb-3">
|
||||
<legend class="col-sm-3 col-form-label">
|
||||
<?= I18N::translate('Subject prefix') ?>
|
||||
</label>
|
||||
</legend>
|
||||
<div class="col-sm-9">
|
||||
<input class="form-control" type="text"
|
||||
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
|
||||
value="<?= e($subject) ?>">
|
||||
<small class="form-text text-muted d-block mb-2">
|
||||
<?= I18N::translate('Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.') ?>
|
||||
</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>
|
||||
</fieldset>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
|
||||
@@ -138,6 +167,44 @@ use Illuminate\Support\Collection;
|
||||
</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 tree’s 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">
|
||||
<<?= e($user->email()) ?>>
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($last_sent > 0) : ?>
|
||||
<div class="row mb-3">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
|
||||
@@ -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<string,string> 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).
|
||||
*
|
||||
|
||||
+55
-2
@@ -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-<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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user