From 7ce8201082582651329c09de044c228d68463f49 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 12:00:39 +0200 Subject: [PATCH] Initial commit: webtrees Email Newsletter module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- LICENSE | 9 + README.md | 123 ++++++++ composer.json | 32 ++ module.php | 23 ++ resources/views/admin.phtml | 195 ++++++++++++ resources/views/edit-account-page.phtml | 211 +++++++++++++ resources/views/email.phtml | 198 +++++++++++++ src/Configuration.php | 116 ++++++++ src/Http/AccountUpdateDecorator.php | 56 ++++ src/Module.php | 327 +++++++++++++++++++++ src/Services/EventQueryService.php | 107 +++++++ src/Services/ExtraRecipient.php | 52 ++++ src/Services/NewsletterDispatchService.php | 299 +++++++++++++++++++ 13 files changed, 1748 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 module.php create mode 100644 resources/views/admin.phtml create mode 100644 resources/views/edit-account-page.phtml create mode 100644 resources/views/email.phtml create mode 100644 src/Configuration.php create mode 100644 src/Http/AccountUpdateDecorator.php create mode 100644 src/Module.php create mode 100644 src/Services/EventQueryService.php create mode 100644 src/Services/ExtraRecipient.php create mode 100644 src/Services/NewsletterDispatchService.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ec6ee7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies of this + license document, but changing it is not allowed. + +The full text of the GNU Affero General Public License v3.0 is +available at . diff --git a/README.md b/README.md new file mode 100644 index 0000000..70b7a8c --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# webtrees Email Newsletter + +A [webtrees](https://www.webtrees.net/) 2.2+ custom module that sends recurring +email newsletters with: + +- **Upcoming birthdays** of still-living individuals. +- **Upcoming marriage anniversaries** of intact couples (optional — admin + toggle, per tree). Marriages with a divorce or annulment fact are excluded + automatically. +- **Once-per-month historical section**: births and deaths of deceased + individuals whose anniversary falls in the upcoming window. + +The decision to actually send is made by comparing a stored "last sent" +timestamp to the configured frequency, so the dispatch run is idempotent — +calling the trigger more often than the frequency simply does nothing +extra. + +## Requirements + +- webtrees ≥ 2.2.0 +- PHP ≥ 8.2 +- A working SMTP / sendmail configuration in *webtrees → Control panel → + Sending email* (this module reuses webtrees' standard mailer). +- An external scheduler on the host: system `cron`, a `systemd` timer, + a Kubernetes `CronJob`, or anything else that can fire an HTTP request + at a fixed interval. **Newsletter dispatch never runs on visitor page + loads — it only runs when the scheduler triggers it.** + +## Installation + +1. Copy this directory into the webtrees `modules_v4/` folder, renaming + it to `email_newsletter` (the folder name determines the internal + module identifier — the registered name will be + `_email_newsletter_`). + + ```sh + cp -r webtrees_email_newsletter /var/www/webtrees/modules_v4/email_newsletter + ``` + +2. In the webtrees control panel, go to *Modules → All modules* and + enable **Email Newsletter**. + +3. Open *Control panel → Modules → Email Newsletter → Preferences* and: + - Enable newsletter dispatch per tree. + - Pick a frequency (default: 14 days). + - Optionally toggle marriage anniversaries and add any extra + external email addresses. + - Copy the **Cron URL** at the bottom — this is the secret-token + URL your scheduler must hit. + +## Setting up the scheduler + +> **Why no built-in scheduler?** PHP has no daemon, and frameworks like +> Laravel rely on a once-per-minute system cron to fire their internal +> scheduler. This module follows the same convention: the host OS owns +> the timer, the module owns the "is it actually due?" decision. + +### System cron + +```cron +# Run every 15 minutes. The module itself decides whether sending is due. +*/15 * * * * curl -fsS --max-time 60 'https://example.com/module/_email_newsletter_/Cron?token=YOUR_TOKEN' > /dev/null +``` + +### systemd timer + +`/etc/systemd/system/webtrees-newsletter.service`: + +```ini +[Unit] +Description=webtrees newsletter trigger + +[Service] +Type=oneshot +ExecStart=/usr/bin/curl -fsS --max-time 60 "https://example.com/module/_email_newsletter_/Cron?token=YOUR_TOKEN" +``` + +`/etc/systemd/system/webtrees-newsletter.timer`: + +```ini +[Unit] +Description=Trigger webtrees newsletter dispatch + +[Timer] +OnCalendar=*-*-* *:00/15 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +Then `systemctl enable --now webtrees-newsletter.timer`. + +### Forcing a one-off send + +The admin **Preferences** page has a *Send now* button for testing. +For an unattended one-off send, append `&force=1` to the cron URL — +that bypasses the "is it due?" check. + +## Subscribers + +Two sources, combined: + +1. **Logged-in webtrees users** who opt in via the per-tree + *Newsletter subscription* menu entry (visible only to logged-in + users on trees where the module is enabled). Only **approved and + email-verified** accounts will receive the newsletter. +2. **External addresses** the tree administrator lists in the + *Extra recipient email addresses* textarea (one per line). + +## Privacy + +The dispatch service does not impersonate a webtrees user, so it sees +the tree from the **visitor** access level. Records and facts that +your tree settings hide from visitors will be omitted from the +newsletter even if a recipient has higher in-app access. This is the +safest default for an outbound email — if you need to expose more +information, relax the tree's visitor-access settings or hand-curate +the *Extra recipient* list. + +## License + +AGPL-3.0-or-later. See `LICENSE` for the full text. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3212367 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "alex/webtrees-email-newsletter", + "description": "Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events from the family tree.", + "license": "AGPL-3.0-or-later", + "type": "webtrees-module", + "keywords": [ + "webtrees", + "module", + "newsletter", + "email", + "birthday", + "anniversary" + ], + "authors": [ + { + "name": "Alex", + "role": "Developer" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.2", + "ext-json": "*", + "fisharebest/webtrees": "~2.2.0 || dev-main" + }, + "autoload": { + "psr-4": { + "EmailNewsletter\\": "src/" + } + } +} diff --git a/module.php b/module.php new file mode 100644 index 0000000..53afe05 --- /dev/null +++ b/module.php @@ -0,0 +1,23 @@ +addPsr4('EmailNewsletter\\', __DIR__ . '/src'); +$loader->register(); + +return Registry::container()->get(Module::class); diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml new file mode 100644 index 0000000..0ccd005 --- /dev/null +++ b/resources/views/admin.phtml @@ -0,0 +1,195 @@ + $all_trees + * @var string $cron_token + * @var string $cron_url + * @var string $title + */ + +?> + + [ + route(ControlPanel::class) => I18N::translate('Control panel'), + route(ModulesAllPage::class) => I18N::translate('Modules'), + $title, + ], +]) ?> + +

+ +

+ +

+ +
+ + + + id(); + $enabled = Configuration::isEnabled($tree); + $frequency = Configuration::frequencyDays($tree); + $lookahead = Configuration::lookaheadDays($tree); + $histLook = Configuration::historicalLookaheadDays($tree); + $annivs = Configuration::includeAnniversaries($tree); + $subject = Configuration::subjectPrefix($tree); + $extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, ''); + $last_sent = Configuration::lastSentAt($tree); + ?> + +
+ title()) ?> + +
+
+
+
+ > + +
+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+
+ +
+
+
+ > + +
+ + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + 0) : ?> +
+
+ + + +
+
+ +
+
+ + +
+ +
+

+ +

+
+
+ + +
+
+
+ + +
+ +
+ + +
diff --git a/resources/views/edit-account-page.phtml b/resources/views/edit-account-page.phtml new file mode 100644 index 0000000..cd073ce --- /dev/null +++ b/resources/views/edit-account-page.phtml @@ -0,0 +1,211 @@ + $contact_methods + * @var Individual|null $default_individual + * @var array $languages + * @var Individual|null $my_individual_record + * @var bool $show_delete_option + * @var array $timezones + * @var string $title + * @var Tree|null $tree + * @var UserInterface $user + */ + +?> + +

+ +

+ + + + +
+
+ +
+ diff --git a/resources/views/email.phtml b/resources/views/email.phtml new file mode 100644 index 0000000..d2d8097 --- /dev/null +++ b/resources/views/email.phtml @@ -0,0 +1,198 @@ + $birthdays + * @var Collection|null $anniversaries + * @var Collection|null $historical + * @var bool $include_anniversaries + * @var bool $include_historical + * @var int $lookahead_days + * @var int $historical_lookahead + * @var int $generated_at + * @var string $account_url + */ + +$record_label = static function (Fact $fact): string { + $record = $fact->record(); + + if ($record instanceof Individual) { + return strip_tags($record->fullName()); + } + + if ($record instanceof Family) { + $husband = $record->husband(); + $wife = $record->wife(); + $names = array_filter([ + $husband !== null ? strip_tags($husband->fullName()) : '', + $wife !== null ? strip_tags($wife->fullName()) : '', + ]); + + return implode(' & ', $names); + } + + return $record->xref(); +}; + +$event_date = static function (Fact $fact): string { + $date = $fact->date(); + + if (!$date instanceof Date || !$date->isOK()) { + return ''; + } + + return strip_tags($date->display()); +}; + +/** + * Age the person/couple actually turns on the upcoming anniversary, not + * their current age. We use the fact's own year (which on an anniversary + * Fact is the year of the original event — birth or marriage) and the + * year of the upcoming Julian day stored on the Fact ($fact->jd) so the + * calculation handles people whose birthday falls before vs. after today + * uniformly. + */ +$upcoming_age = static function (Fact $fact): int { + static $gregorian = null; + $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); + + $date = $fact->date(); + + if (!$date->isOK()) { + return 0; + } + + $event_year = $date->gregorianYear(); + $upcoming_jd = $fact->jd ?? 0; + + if ($upcoming_jd > 0) { + [$upcoming_year] = $gregorian->jdToYmd($upcoming_jd); + } else { + $upcoming_year = (int) date('Y'); + } + + return max(0, $upcoming_year - $event_year); +}; + +/** + * Locale-aware ordinal. English uses st/nd/rd/th suffixes; German (and + * most other European languages we currently support) just appends a + * period to the digits. + */ +$ordinal = static function (int $n): string { + if (str_starts_with(I18N::languageTag(), 'de')) { + return $n . '.'; + } + + $abs = abs($n); + $mod100 = $abs % 100; + + if ($mod100 >= 11 && $mod100 <= 13) { + return $n . 'th'; + } + + return $n . match ($abs % 10) { + 1 => 'st', + 2 => 'nd', + 3 => 'rd', + default => 'th', + }; +}; + +$birthday_label = static function (int $age) use ($ordinal): string { + if ($age <= 0) { + return I18N::translate('Birthday'); + } + + return I18N::translate('%s birthday', $ordinal($age)); +}; + +$anniversary_label = static function (int $age) use ($ordinal): string { + if ($age <= 0) { + return I18N::translate('Wedding anniversary'); + } + + return I18N::translate('%s wedding anniversary', $ordinal($age)); +}; + +?> + + + +<?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?> + + + +

+ title()) ?> +

+ +

+ +

+ +isEmpty()) : ?> +

+
    + + +
  • + + — + () +
  • + +
+ + +isEmpty()) : ?> +

+
    + + +
  • + + — + () +
  • + +
+ + +isEmpty()) : ?> +

+

+ +

+
    + +
  • + + — label()) ?>: +
  • + +
+ + +
+

+ title())) ?> +
+ ' . e(I18N::translate('My account')) . '', + ) ?> +

+ + + diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..1a06f1d --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,116 @@ +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 + */ + 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); + } +} diff --git a/src/Http/AccountUpdateDecorator.php b/src/Http/AccountUpdateDecorator.php new file mode 100644 index 0000000..2d36247 --- /dev/null +++ b/src/Http/AccountUpdateDecorator.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/src/Module.php b/src/Module.php new file mode 100644 index 0000000..fd55f27 --- /dev/null +++ b/src/Module.php @@ -0,0 +1,327 @@ +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(), + ]); + } + +} diff --git a/src/Services/EventQueryService.php b/src/Services/EventQueryService.php new file mode 100644 index 0000000..2d0649a --- /dev/null +++ b/src/Services/EventQueryService.php @@ -0,0 +1,107 @@ + + */ + 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 + */ + 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 + */ + 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]; + } +} diff --git a/src/Services/ExtraRecipient.php b/src/Services/ExtraRecipient.php new file mode 100644 index 0000000..34b0b3e --- /dev/null +++ b/src/Services/ExtraRecipient.php @@ -0,0 +1,52 @@ +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. + } +} diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php new file mode 100644 index 0000000..4d0a31c --- /dev/null +++ b/src/Services/NewsletterDispatchService.php @@ -0,0 +1,299 @@ + 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 $recipients + * @return array> + */ + 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 + */ + 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); + } +}