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