Initial commit: webtrees Email Newsletter module

Recurring email newsletter for webtrees 2.2+. Each enabled tree
sends upcoming birthdays of living individuals, optional marriage
anniversaries of intact couples, and a once-per-calendar-month
historical section of births and deaths of deceased individuals.

Triggered exclusively by an external scheduler (system cron,
systemd timer, etc.) hitting a token-gated HTTP endpoint — never
on visitor page loads. The "is it due?" decision is idempotent
within the configured frequency window.

Per-user subscription is integrated into the built-in
/my-account/{tree} page via a custom view + a decorated
AccountUpdate handler. Admins can add external addresses and
trigger an immediate send for testing. Email body renders in
German for German-language users; English otherwise. Birthdays
and anniversaries are formatted with the upcoming-event ordinal
age (e.g. "45th birthday" / "45. Geburtstag").
This commit is contained in:
2026-05-15 12:00:39 +02:00
commit 7ce8201082
13 changed files with 1748 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter;
use Fisharebest\Webtrees\Tree;
/**
* Per-tree configuration for the Email Newsletter module.
*
* All values are persisted via $tree->setPreference()/getPreference().
* State values (last-sent timestamp, last historical-section month) are
* also stored on the tree because each tree produces its own newsletter.
*/
final class Configuration
{
// webtrees stores all *_setting tables with a varchar(32) setting_name
// column. Keys here MUST stay <= 32 characters.
public const string PREF_ENABLED = 'NEWSLETTER_ENABLED';
public const string PREF_FREQUENCY_DAYS = 'NEWSLETTER_FREQ_DAYS';
public const string PREF_LOOKAHEAD_DAYS = 'NEWSLETTER_LOOK_DAYS';
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT';
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
// Module-level (not tree-bound) settings.
public const string MODULE_PREF_CRON_TOKEN = 'cron_token';
// Per-user subscription preference. Set via $user->setPreference().
public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed';
public const int DEFAULT_FREQUENCY_DAYS = 14;
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
public const int MIN_FREQUENCY_DAYS = 1;
public const int MAX_FREQUENCY_DAYS = 90;
public const int MIN_LOOKAHEAD_DAYS = 1;
public const int MAX_LOOKAHEAD_DAYS = 60;
public static function isEnabled(Tree $tree): bool
{
return $tree->getPreference(self::PREF_ENABLED) === '1';
}
public static function frequencyDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_FREQUENCY_DAYS, (string) self::DEFAULT_FREQUENCY_DAYS);
return max(self::MIN_FREQUENCY_DAYS, min(self::MAX_FREQUENCY_DAYS, $value));
}
public static function lookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_LOOKAHEAD_DAYS, (string) self::DEFAULT_LOOKAHEAD_DAYS);
return max(self::MIN_LOOKAHEAD_DAYS, min(self::MAX_LOOKAHEAD_DAYS, $value));
}
public static function includeAnniversaries(Tree $tree): bool
{
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
}
public static function historicalLookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(
self::PREF_HISTORICAL_LOOKAHEAD,
(string) self::DEFAULT_HISTORICAL_LOOKAHEAD,
);
return max(7, min(60, $value));
}
public static function subjectPrefix(Tree $tree): string
{
return $tree->getPreference(self::PREF_SUBJECT_PREFIX, '[' . $tree->title() . '] ');
}
/**
* Extra recipient email addresses configured by the admin (one per line).
*
* @return array<int,string>
*/
public static function extraRecipients(Tree $tree): array
{
$raw = $tree->getPreference(self::PREF_EXTRA_RECIPIENTS, '');
$lines = preg_split('/\R/', $raw) ?: [];
$lines = array_map('trim', $lines);
return array_values(array_filter($lines, static fn (string $line): bool => $line !== ''));
}
public static function lastSentAt(Tree $tree): int
{
return (int) $tree->getPreference(self::PREF_LAST_SENT_AT, '0');
}
public static function setLastSentAt(Tree $tree, int $timestamp): void
{
$tree->setPreference(self::PREF_LAST_SENT_AT, (string) $timestamp);
}
public static function lastHistoricalMonth(Tree $tree): string
{
return $tree->getPreference(self::PREF_LAST_HISTORICAL_MONTH, '');
}
public static function setLastHistoricalMonth(Tree $tree, string $yearMonth): void
{
$tree->setPreference(self::PREF_LAST_HISTORICAL_MONTH, $yearMonth);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Http;
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Fisharebest\Webtrees\Validator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Wraps the built-in AccountUpdate handler and adds persistence of the
* "Subscribe to the newsletter" checkbox we inject into the
* edit-account-page view.
*
* The webtrees AccountUpdate class is final, so we decorate it rather
* than subclassing it. The wrapped handler runs first (so the user's
* own changes go through the normal validation path); afterwards we
* persist the per-tree subscription flag.
*
* This handler is bound in place of AccountUpdate via the webtrees DI
* container — see EmailNewsletter\Module::boot().
*/
final class AccountUpdateDecorator implements RequestHandlerInterface
{
public function __construct(
private readonly AccountUpdate $inner,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$response = $this->inner->handle($request);
$tree = Validator::attributes($request)->treeOptional();
$user = Validator::attributes($request)->user();
if ($tree instanceof Tree && $user instanceof User) {
$subscribed = Validator::parsedBody($request)
->boolean('newsletter_subscribed', false);
$tree->setUserPreference(
$user,
Configuration::USER_PREF_SUBSCRIBED,
$subscribed ? '1' : '0',
);
}
return $response;
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* Email Newsletter module for webtrees.
*
* @license AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace EmailNewsletter;
use EmailNewsletter\Http\AccountUpdateDecorator;
use EmailNewsletter\Services\NewsletterDispatchService;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\FlashMessages;
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Menu;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\View;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
{
use ModuleCustomTrait;
use ModuleConfigTrait;
use ModuleMenuTrait;
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
public function __construct(
private readonly NewsletterDispatchService $dispatch_service,
private readonly TreeService $tree_service,
) {
}
public function title(): string
{
return I18N::translate('Email Newsletter');
}
public function description(): string
{
return I18N::translate('Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.');
}
public function customModuleAuthorName(): string
{
return 'Alex';
}
public function customModuleVersion(): string
{
return '1.0.0';
}
public function customModuleSupportUrl(): string
{
return '';
}
public function resourcesFolder(): string
{
return __DIR__ . '/../resources/';
}
public function boot(): void
{
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
// Inject our newsletter-subscription field into the built-in
// /my-account page and persist it via a decorated handler.
View::registerCustomView('::edit-account-page', $this->name() . '::edit-account-page');
$container = Registry::container();
$container->set(
AccountUpdate::class,
new AccountUpdateDecorator($container->get(AccountUpdate::class)),
);
}
public function customTranslations(string $language): array
{
$translations = [
'de' => [
'Email Newsletter' => 'E-Mail-Newsletter',
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
=> 'Versendet regelmäßige Newsletter mit anstehenden Geburtstagen, Hochzeitstagen und historischen Ereignissen.',
'Family newsletter — %s' => 'Familien-Newsletter — %s',
'Upcoming birthdays' => 'Anstehende Geburtstage',
'Upcoming marriage anniversaries' => 'Anstehende Hochzeitstage',
'On this month in history' => 'In diesem Monat in der Geschichte',
'Newsletter subscription' => 'Newsletter-Abonnement',
'Subscribe to the newsletter' => 'Newsletter abonnieren',
'Send newsletters every' => 'Newsletter senden alle',
'days' => 'Tage',
'Look ahead' => 'Vorschau',
'Include marriage anniversaries' => 'Hochzeitstage einbeziehen',
'Historical look-ahead (days)' => 'Historische Vorschau (Tage)',
'Extra recipient email addresses (one per line)' => 'Zusätzliche Empfänger-E-Mail-Adressen (eine pro Zeile)',
'Subject prefix' => 'Betreff-Präfix',
'Save' => 'Speichern',
'Send now' => 'Jetzt senden',
'Cron token' => 'Cron-Token',
'Regenerate token' => 'Token neu generieren',
'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.',
'%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag',
'Wedding anniversary' => 'Hochzeitstag',
'Events in the next %d days.' => 'Ereignisse in den nächsten %d Tagen.',
'Events in the next %d days for people who have passed away.'
=> 'Ereignisse in den nächsten %d Tagen für bereits verstorbene Personen.',
'%d years old' => '%d Jahre alt',
'%d years' => '%d Jahre',
'You will receive a periodic email with upcoming birthdays and other family events from %s.'
=> 'Sie erhalten regelmäßig eine E-Mail mit anstehenden Geburtstagen und weiteren Familienereignissen aus %s.',
'You are receiving this email because you subscribed to the %s newsletter.'
=> 'Sie erhalten diese E-Mail, weil Sie den Newsletter „%s“ abonniert haben.',
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.'
=> 'Um Ihr Abonnement zu ändern oder zu kündigen, bearbeiten Sie den Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s.',
'Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).'
=> 'Newsletter-Versand pro Stammbaum konfigurieren. Absender ist die Kontaktperson des jeweiligen Baums (alternativ der Webmaster der Seite).',
'Enable newsletter for this tree' => 'Newsletter für diesen Baum aktivieren',
'Only intact marriages of still-living couples are included.'
=> 'Nur bestehende Ehen lebender Paare werden berücksichtigt.',
'Births and deaths of deceased people are included once per calendar month.'
=> 'Geburten und Todestage verstorbener Personen werden einmal pro Kalendermonat einbezogen.',
'Last sent: %s' => 'Zuletzt gesendet: %s',
'Configure your system cron, systemd timer, or any external scheduler to call the URL below. The schedule decides when newsletters are actually due — calling more frequently is safe.'
=> '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?',
],
'nl' => [
'Email Newsletter' => 'E-mailnieuwsbrief',
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
=> 'Verstuurt periodieke nieuwsbrieven met aankomende verjaardagen, trouwdagen en historische gebeurtenissen.',
'Family newsletter — %s' => 'Familienieuwsbrief — %s',
'Upcoming birthdays' => 'Aankomende verjaardagen',
'Upcoming marriage anniversaries' => 'Aankomende trouwdagen',
'On this month in history' => 'Deze maand in de geschiedenis',
'Newsletter subscription' => 'Nieuwsbriefabonnement',
'Subscribe to the newsletter' => 'Abonneren op de nieuwsbrief',
'Send newsletters every' => 'Nieuwsbrieven verzenden elke',
'days' => 'dagen',
'Look ahead' => 'Vooruitkijken',
'Include marriage anniversaries' => 'Trouwdagen meenemen',
'Historical look-ahead (days)' => 'Historische vooruitblik (dagen)',
'Extra recipient email addresses (one per line)' => 'Extra ontvanger-e-mailadressen (één per regel)',
'Subject prefix' => 'Onderwerpvoorvoegsel',
'Save' => 'Opslaan',
'Send now' => 'Nu verzenden',
'Cron token' => 'Cron-token',
'Regenerate token' => 'Token opnieuw genereren',
'Your subscription has been updated.' => 'Uw abonnement is bijgewerkt.',
],
];
return $translations[$language] ?? [];
}
// ─── Menu ────────────────────────────────────────────────────────
public function defaultMenuOrder(): int
{
return 99;
}
public function getMenu(Tree $tree): Menu|null
{
if (!Auth::check()) {
return null;
}
if (!Configuration::isEnabled($tree)) {
return null;
}
return new Menu(
I18N::translate('Newsletter subscription'),
route(AccountEdit::class, ['tree' => $tree->name()]),
'menu-newsletter-subscription',
);
}
// ─── Admin config page ──────────────────────────────────────────
public function getAdminAction(): ResponseInterface
{
$this->layout = 'layouts/administration';
return $this->viewResponse($this->name() . '::admin', [
'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'),
'module' => $this,
'all_trees' => $this->tree_service->all(),
'cron_token' => $this->cronToken(),
'cron_url' => $this->cronUrl(),
]);
}
public function postAdminAction(ServerRequestInterface $request): ResponseInterface
{
foreach ($this->tree_service->all() as $tree) {
$id = $tree->id();
$enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1';
$frequency = Validator::parsedBody($request)
->isBetween(Configuration::MIN_FREQUENCY_DAYS, Configuration::MAX_FREQUENCY_DAYS)
->integer('frequency-' . $id, Configuration::DEFAULT_FREQUENCY_DAYS);
$lookahead = Validator::parsedBody($request)
->isBetween(Configuration::MIN_LOOKAHEAD_DAYS, Configuration::MAX_LOOKAHEAD_DAYS)
->integer('lookahead-' . $id, Configuration::DEFAULT_LOOKAHEAD_DAYS);
$histLook = Validator::parsedBody($request)
->isBetween(7, 60)
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
$subject = Validator::parsedBody($request)->string('subject-' . $id, '');
$tree->setPreference(Configuration::PREF_ENABLED, $enabled ? '1' : '0');
$tree->setPreference(Configuration::PREF_FREQUENCY_DAYS, (string) $frequency);
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
$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);
}
}
if (Validator::parsedBody($request)->string('regenerate_token', '0') === '1') {
$this->setPreference(self::SETTING_CRON_TOKEN, $this->generateToken());
}
FlashMessages::addMessage(
I18N::translate('The preferences for the module “%s” have been updated.', $this->title()),
'success',
);
return redirect($this->getConfigLink());
}
/**
* Admin-triggered immediate run (bypasses the "due" check, hits all
* enabled trees). Useful for testing without waiting for the timer.
*/
public function postSendNowAdminAction(ServerRequestInterface $request): ResponseInterface
{
$log = $this->dispatch_service->dispatch($this, true);
foreach ($log as $line) {
FlashMessages::addMessage($line, 'info');
}
return redirect($this->getConfigLink());
}
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
/**
* Triggered by an external timer (system cron, systemd timer, etc.)
* via a plain HTTP GET. Authentication is by shared-secret token —
* compared in constant time. No webtrees session is required.
*
* Never invoked by ordinary website traffic.
*/
public function getCronAction(ServerRequestInterface $request): ResponseInterface
{
$supplied = Validator::queryParams($request)->string('token', '');
$expected = $this->cronToken();
if ($expected === '' || !hash_equals($expected, $supplied)) {
throw new HttpAccessDeniedException('Invalid cron token');
}
$force = Validator::queryParams($request)->string('force', '0') === '1';
$log = $this->dispatch_service->dispatch($this, $force);
return response(
"Newsletter dispatch run\n" . implode("\n", $log) . "\n",
)->withHeader('Content-Type', 'text/plain; charset=utf-8');
}
// ─── Helpers ────────────────────────────────────────────────────
private function cronToken(): string
{
$token = $this->getPreference(self::SETTING_CRON_TOKEN);
if ($token === '') {
$token = $this->generateToken();
$this->setPreference(self::SETTING_CRON_TOKEN, $token);
}
return $token;
}
private function generateToken(): string
{
return bin2hex(random_bytes(24));
}
private function cronUrl(): string
{
return route('module', [
'module' => $this->name(),
'action' => 'Cron',
'token' => $this->cronToken(),
]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\CalendarService;
use Fisharebest\Webtrees\Tree;
use Illuminate\Support\Collection;
/**
* Collects the events that should appear in the newsletter.
*
* Wraps CalendarService and adds the project-specific filtering rules
* (living vs. deceased, "marriage still active", historical section).
*/
final class EventQueryService
{
public function __construct(
private readonly CalendarService $calendar_service,
) {
}
/**
* Birthdays of still-living individuals in the upcoming window.
*
* Window is [tomorrow, tomorrow + days - 1] so we never re-announce
* a birthday on the very day of the next scheduled send.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingBirthdays(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
return $this->calendar_service
->getEventsList($start, $end, 'BIRT', true, 'anniv', $tree);
}
/**
* Marriage anniversaries of intact couples — both spouses still
* living, and no divorce/annulment fact recorded.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingAnniversaries(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
$facts = $this->calendar_service
->getEventsList($start, $end, 'MARR', true, 'anniv', $tree);
// CalendarService already filtered out families where either
// spouse is dead. We additionally drop families that have a
// DIV, DIVF or ANUL fact recorded.
return $facts->filter(static function ($fact): bool {
$record = $fact->record();
if (!$record instanceof Family) {
return false;
}
foreach ($record->facts(['DIV', 'DIVF', 'ANUL']) as $end_fact) {
if ($end_fact->canShow()) {
return false;
}
}
return true;
})->values();
}
/**
* Births and deaths of deceased individuals in the upcoming window.
* Used for the once-per-month historical section.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingHistoricalEvents(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
$facts = $this->calendar_service
->getEventsList($start, $end, 'BIRT|DEAT', false, 'anniv', $tree);
return $facts->filter(static function ($fact): bool {
$record = $fact->record();
return $record instanceof Individual && $record->isDead();
})->values();
}
/**
* @return array{0:int,1:int} Julian day numbers for the window.
*/
private function window(int $days): array
{
$now = Registry::timestampFactory()->now();
$start = $now->addDays(1)->julianDay();
$end = $now->addDays($days)->julianDay();
return [$start, $end];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Contracts\UserInterface;
/**
* Minimal UserInterface implementation for admin-added recipients that
* aren't backed by a webtrees user account. EmailService::send accepts
* any UserInterface, so this lets us reuse the same pipeline for both
* subscribed users and external addresses.
*/
final class ExtraRecipient implements UserInterface
{
public function __construct(
private readonly string $email,
private readonly string $real_name = '',
) {
}
public function id(): int
{
return 0;
}
public function email(): string
{
return $this->email;
}
public function realName(): string
{
return $this->real_name !== '' ? $this->real_name : $this->email;
}
public function userName(): string
{
return $this->email;
}
public function getPreference(string $setting_name, string $default = ''): string
{
return $default;
}
public function setPreference(string $setting_name, string $setting_value): void
{
// External recipients have no preferences to persist.
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Module\ModuleInterface;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Exception\RfcComplianceException;
/**
* Decides which trees are due, builds per-tree newsletters, and dispatches
* them via the webtrees EmailService.
*
* The "due" decision is based solely on stored timestamps and the
* configured frequency. It is intentionally independent of who triggered
* the run, so this is safe to call from an external cron without
* worrying about double-sends from concurrent invocations (idempotent
* within the same window, modulo a very small race the lock guards).
*/
final class NewsletterDispatchService
{
/**
* Anonymous "from" address fallback when the site has no contact user.
*/
private const string FROM_NAME = 'webtrees newsletter';
public function __construct(
private readonly EventQueryService $event_query_service,
private readonly EmailService $email_service,
private readonly TreeService $tree_service,
private readonly UserService $user_service,
) {
}
/**
* Run the dispatch cycle for every tree.
*
* @return array<int,string> Human-readable log lines describing what
* happened, suitable for the cron endpoint
* to return to the caller.
*/
public function dispatch(ModuleInterface $module, bool $force = false): array
{
$log = [];
$now = time();
foreach ($this->tree_service->all() as $tree) {
if (!Configuration::isEnabled($tree)) {
$log[] = sprintf('Tree "%s": disabled, skipping.', $tree->name());
continue;
}
$due_at = Configuration::lastSentAt($tree)
+ Configuration::frequencyDays($tree) * 86400;
if (!$force && $now < $due_at) {
$log[] = sprintf(
'Tree "%s": not due yet (next send in %d hours).',
$tree->name(),
(int) (($due_at - $now) / 3600),
);
continue;
}
$log[] = $this->dispatchForTree($tree, $module, $now);
}
return $log;
}
private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now): string
{
$include_anniversaries = Configuration::includeAnniversaries($tree);
$lookahead = Configuration::lookaheadDays($tree);
$historical_lookahead = Configuration::historicalLookaheadDays($tree);
$current_month = date('Y-m', $now);
$include_historical = Configuration::lastHistoricalMonth($tree) !== $current_month;
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $lookahead);
$anniversaries = $include_anniversaries
? $this->event_query_service->upcomingAnniversaries($tree, $lookahead)
: null;
$historical = $include_historical
? $this->event_query_service->upcomingHistoricalEvents($tree, $historical_lookahead)
: null;
// Suppress entirely empty newsletters so subscribers don't get
// a near-empty email on a slow fortnight.
$has_content = !$birthdays->isEmpty()
|| ($anniversaries !== null && !$anniversaries->isEmpty())
|| ($historical !== null && !$historical->isEmpty());
if (!$has_content) {
Configuration::setLastSentAt($tree, $now);
return sprintf('Tree "%s": nothing to report, send timestamp advanced.', $tree->name());
}
$recipients = $this->collectRecipients($tree);
if ($recipients === []) {
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
}
$from = $this->siteContact($tree);
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
$sent = 0;
$failures = 0;
try {
foreach ($groups as $lang => $group) {
I18N::init($lang);
$subject = Configuration::subjectPrefix($tree) . I18N::translate(
'Family newsletter — %s',
date('F j, Y', $now),
);
$html = view($module->name() . '::email', [
'tree' => $tree,
'birthdays' => $birthdays,
'anniversaries' => $anniversaries,
'historical' => $historical,
'include_anniversaries' => $include_anniversaries,
'include_historical' => $include_historical,
'lookahead_days' => $lookahead,
'historical_lookahead' => $historical_lookahead,
'generated_at' => $now,
'account_url' => route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
),
]);
$text = $this->htmlToText($html);
foreach ($group as $recipient) {
try {
if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) {
$sent++;
} else {
$failures++;
}
} catch (RfcComplianceException | TransportExceptionInterface $ex) {
$failures++;
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
}
}
}
} finally {
// Always restore the original locale, even if a render or
// send throws an unexpected exception.
I18N::init($original_locale);
}
Configuration::setLastSentAt($tree, $now);
if ($include_historical) {
Configuration::setLastHistoricalMonth($tree, $current_month);
}
return sprintf(
'Tree "%s": sent to %d recipient(s), %d failure(s)%s.',
$tree->name(),
$sent,
$failures,
$include_historical ? ', monthly historical section included' : '',
);
}
/**
* Group recipients by the language we will render their email in.
* German users get "de"; everyone else (including admin-added
* external addresses) gets "en-US" for now.
*
* @param array<int,UserInterface> $recipients
* @return array<string,array<int,UserInterface>>
*/
private function groupRecipientsByLanguage(array $recipients): array
{
$groups = [];
foreach ($recipients as $recipient) {
$pref = $recipient->getPreference(UserInterface::PREF_LANGUAGE, '');
$lang = str_starts_with(strtolower($pref), 'de') ? 'de' : 'en-US';
$groups[$lang][] = $recipient;
}
return $groups;
}
/**
* @return array<int,UserInterface>
*/
private function collectRecipients(Tree $tree): array
{
$recipients = [];
$seen = [];
foreach ($this->user_service->all() as $user) {
if (!$user instanceof User) {
continue;
}
// Only deliver to approved + email-verified accounts.
if ($user->getPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
continue;
}
if ($user->getPreference(UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
continue;
}
if ($tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) !== '1') {
continue;
}
$email = trim($user->email());
if ($email === '' || isset($seen[strtolower($email)])) {
continue;
}
$seen[strtolower($email)] = true;
$recipients[] = $user;
}
foreach (Configuration::extraRecipients($tree) as $email) {
$key = strtolower($email);
if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) {
continue;
}
$seen[$key] = true;
$recipients[] = new ExtraRecipient($email);
}
return $recipients;
}
/**
* Pick a sender identity. Fall back to a synthetic UserInterface if
* the site has no contact user configured for this tree.
*/
private function siteContact(Tree $tree): UserInterface
{
$contact_id = (int) $tree->getPreference('CONTACT_USER_ID');
if ($contact_id !== 0) {
$user = $this->user_service->find($contact_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
$webmaster_id = (int) \Fisharebest\Webtrees\Site::getPreference('WEBMASTER_USER_ID');
if ($webmaster_id !== 0) {
$user = $this->user_service->find($webmaster_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
// Last resort: derive a sender address from the server hostname.
// The mail transport must accept this; admins will normally have
// a contact user set, so this only matters for fresh installs.
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$address = 'no-reply@' . preg_replace('/[^a-zA-Z0-9.\-]/', '', $host);
return new ExtraRecipient($address, self::FROM_NAME);
}
private function htmlToText(string $html): string
{
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
$without_tags = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $without_tags) ?? $without_tags;
$stripped = strip_tags($without_tags);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Collapse runs of blank lines.
return trim(preg_replace("/\n{3,}/", "\n\n", $decoded) ?? $decoded);
}
}