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;