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
+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(),
]);
}
}