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:
+327
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user