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
+9
View File
@@ -0,0 +1,9 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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 <https://www.gnu.org/licenses/agpl-3.0.txt>.
+123
View File
@@ -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.
+32
View File
@@ -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/"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* Email Newsletter module for webtrees.
*
* Sends recurring email newsletters with upcoming birthdays, marriage
* anniversaries, and historical events from the family tree.
*
* @license AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace EmailNewsletter;
use Composer\Autoload\ClassLoader;
use Fisharebest\Webtrees\Registry;
$loader = new ClassLoader();
$loader->addPsr4('EmailNewsletter\\', __DIR__ . '/src');
$loader->register();
return Registry::container()->get(Module::class);
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
use EmailNewsletter\Configuration;
use EmailNewsletter\Module;
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Tree;
use Illuminate\Support\Collection;
/**
* @var Module $module
* @var Collection<int,Tree> $all_trees
* @var string $cron_token
* @var string $cron_url
* @var string $title
*/
?>
<?= view('components/breadcrumbs', [
'links' => [
route(ControlPanel::class) => I18N::translate('Control panel'),
route(ModulesAllPage::class) => I18N::translate('Modules'),
$title,
],
]) ?>
<h1><?= e($title) ?></h1>
<p>
<?= I18N::translate('Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).') ?>
</p>
<form method="post">
<?= csrf_field() ?>
<?php foreach ($all_trees as $tree) : ?>
<?php
$id = $tree->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);
?>
<fieldset class="card mb-4">
<legend class="card-header h4"><?= e($tree->title()) ?></legend>
<div class="card-body">
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="enabled-<?= $id ?>" name="enabled-<?= $id ?>"
value="1" <?= $enabled ? 'checked' : '' ?>>
<label class="form-check-label" for="enabled-<?= $id ?>">
<?= I18N::translate('Enable newsletter for this tree') ?>
</label>
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="frequency-<?= $id ?>">
<?= I18N::translate('Send newsletters every') ?>
</label>
<div class="col-sm-9">
<div class="input-group" style="max-width: 18rem;">
<input class="form-control" type="number"
id="frequency-<?= $id ?>" name="frequency-<?= $id ?>"
value="<?= e((string) $frequency) ?>"
min="<?= Configuration::MIN_FREQUENCY_DAYS ?>"
max="<?= Configuration::MAX_FREQUENCY_DAYS ?>" required>
<span class="input-group-text"><?= I18N::translate('days') ?></span>
</div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="lookahead-<?= $id ?>">
<?= I18N::translate('Look ahead') ?>
</label>
<div class="col-sm-9">
<div class="input-group" style="max-width: 18rem;">
<input class="form-control" type="number"
id="lookahead-<?= $id ?>" name="lookahead-<?= $id ?>"
value="<?= e((string) $lookahead) ?>"
min="<?= Configuration::MIN_LOOKAHEAD_DAYS ?>"
max="<?= Configuration::MAX_LOOKAHEAD_DAYS ?>" required>
<span class="input-group-text"><?= I18N::translate('days') ?></span>
</div>
</div>
</div>
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="anniversaries-<?= $id ?>" name="anniversaries-<?= $id ?>"
value="1" <?= $annivs ? 'checked' : '' ?>>
<label class="form-check-label" for="anniversaries-<?= $id ?>">
<?= I18N::translate('Include marriage anniversaries') ?>
</label>
</div>
<small class="form-text text-muted">
<?= I18N::translate('Only intact marriages of still-living couples are included.') ?>
</small>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="historical-<?= $id ?>">
<?= I18N::translate('Historical look-ahead (days)') ?>
</label>
<div class="col-sm-9">
<input class="form-control" type="number" style="max-width: 18rem;"
id="historical-<?= $id ?>" name="historical-<?= $id ?>"
value="<?= e((string) $histLook) ?>" min="7" max="60" required>
<small class="form-text text-muted">
<?= I18N::translate('Births and deaths of deceased people are included once per calendar month.') ?>
</small>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
<?= I18N::translate('Subject prefix') ?>
</label>
<div class="col-sm-9">
<input class="form-control" type="text"
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
value="<?= e($subject) ?>">
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
<?= I18N::translate('Extra recipient email addresses (one per line)') ?>
</label>
<div class="col-sm-9">
<textarea class="form-control" rows="4"
id="extras-<?= $id ?>" name="extras-<?= $id ?>"><?= e($extras) ?></textarea>
</div>
</div>
<?php if ($last_sent > 0) : ?>
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
<small class="text-muted">
<?= I18N::translate('Last sent: %s', date('Y-m-d H:i', $last_sent)) ?>
</small>
</div>
</div>
<?php endif ?>
</div>
</fieldset>
<?php endforeach ?>
<fieldset class="card mb-4">
<legend class="card-header h4"><?= I18N::translate('Cron token') ?></legend>
<div class="card-body">
<p>
<?= I18N::translate('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.') ?>
</p>
<pre class="bg-light p-2"><?= e($cron_url) ?></pre>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="regenerate_token"
name="regenerate_token" value="1">
<label class="form-check-label" for="regenerate_token">
<?= I18N::translate('Regenerate token') ?>
</label>
</div>
</div>
</fieldset>
<button type="submit" class="btn btn-primary">
<?= view('icons/save') ?>
<?= I18N::translate('Save') ?>
</button>
</form>
<form method="post" action="<?= e(route('module', ['module' => $module->name(), 'action' => 'SendNowAdmin'])) ?>" class="mt-3">
<?= csrf_field() ?>
<button type="submit" class="btn btn-secondary"
onclick="return confirm('<?= e(I18N::translate('Send the newsletter now for every enabled tree?')) ?>');">
<?= I18N::translate('Send now') ?>
</button>
</form>
+211
View File
@@ -0,0 +1,211 @@
<?php
/**
* Customised version of webtrees' built-in edit-account-page view.
*
* Adds a "Newsletter subscription" fieldset that lets each user opt
* in or out per tree. The accompanying form field is processed by
* EmailNewsletter\Http\AccountUpdateDecorator after the built-in
* AccountUpdate handler has run.
*
* Keep the rest of this file in sync with the upstream view from
* webtrees 2.2 — resources/views/edit-account-page.phtml.
*/
declare(strict_types=1);
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountDelete;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Tree;
/**
* @var array<string,string> $contact_methods
* @var Individual|null $default_individual
* @var array<string,string> $languages
* @var Individual|null $my_individual_record
* @var bool $show_delete_option
* @var array<string,string> $timezones
* @var string $title
* @var Tree|null $tree
* @var UserInterface $user
*/
?>
<h2 class="wt-page-title">
<?= $title ?>
</h2>
<form method="post" class="wt-page-options wt-page-options-my-account">
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="user-name">
<?= I18N::translate('Username') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input type="text" class="form-control" id="user-name" name="user_name" value="<?= e($user->userName()) ?>" dir="auto" aria-describedby="username-description" required="required">
<div class="form-text" id="username-description">
<?= I18N::translate('Usernames are case-insensitive and ignore accented letters, so that “chloe”, “chloë”, and “Chloe” are considered to be the same.') ?>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="real-name">
<?= I18N::translate('Real name') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input type="text" class="form-control" id="real-name" name="real_name" value="<?= e($user->realName()) ?>" dir="auto" aria-describedby="real-name-description" required="required">
<div class="form-text" id="real-name-description">
<?= I18N::translate('This is your real name, as you would like it displayed on screen.') ?>
</div>
</div>
</div>
<?php if ($tree instanceof Tree) : ?>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="gedcom-id">
<?= I18N::translate('Individual record') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<select class="form-select" id="gedcom-id" aria-describedby="gedcom-id-description" disabled>
<?php if ($my_individual_record !== null) : ?>
<option value=""><?= $my_individual_record->fullName() ?></option>
<?php else : ?>
<option value=""><?= I18N::translateContext('unknown people', 'Unknown') ?></option>
<?php endif ?>
</select>
<div class="form-text" id="gedcom-id-description">
<?= I18N::translate('This is a link to your own record in the family tree. If this is the wrong individual, contact an administrator.') ?>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="default-xref">
<?= I18N::translate('Default individual') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/select-individual', ['name' => 'default-xref', 'id' => 'default-xref', 'individual' => $default_individual, 'tree' => $tree]) ?>
<div class="form-text" id="default-xref-description">
<?= I18N::translate('This individual will be selected by default when viewing charts and reports.') ?>
</div>
</div>
</div>
<?php endif ?>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="password">
<?= I18N::translate('Password') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input class="form-control" type="password" id="password" name="password" aria-describedby="password-description" autocomplete="new-password" data-wt-show-password-text="<?= e(I18N::translate('show')) ?>" data-wt-show-password-title="<?= e(I18N::translate('Show password')) ?>" data-wt-hide-password-text="<?= e(I18N::translate('hide')) ?>" data-wt-hide-password-title="<?= e(I18N::translate('Hide password')) ?>">
<div class="form-text" id="password-description">
<?= I18N::translate('Passwords must be at least 8 characters long and are case-sensitive, so that “secret” is different from “SECRET”.') ?>
<br>
<?= I18N::translate('Leave the password blank if you want to keep the current password.') ?>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="language">
<?= I18N::translate('Language') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/select', ['name' => 'language', 'selected' => $user->getPreference(UserInterface::PREF_LANGUAGE, 'en-US'), 'options' => $languages]) ?>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="timezone">
<?= I18N::translate('Time zone') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/select', ['name' => 'timezone', 'selected' => $user->getPreference(UserInterface::PREF_TIME_ZONE, 'UTC'), 'options' => $timezones]) ?>
<div class="form-text" id="timezone-description">
<?= I18N::translate('The time zone is required for date calculations, such as knowing todays date.') ?>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="email">
<?= I18N::translate('Email address') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<input class="form-control" type="email" id="email" name="email" value="<?= e($user->email()) ?>" aria-describedby="email-description">
<div class="form-text" id="email-description">
<?= I18N::translate('This email address will be used to send password reminders, website notifications, and messages from other family members who are registered on the website.') ?>
</div>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label wt-page-options-label" for="contact-method">
<?= I18N::translate('Contact method') ?>
</label>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/select', ['name' => 'contact-method', 'id' => 'contact-method', 'selected' => $user->getPreference(UserInterface::PREF_CONTACT_METHOD), 'options' => $contact_methods]) ?>
<div class="form-text" id="contact-method-description">
<?= I18N::translate('Site members can send each other messages. You can choose to how these messages are sent to you, or choose not receive them at all.') ?>
</div>
</div>
</div>
<fieldset class="row">
<legend class="col-sm-3 col-form-label wt-page-options-label">
<?= I18N::translate('Visible online') ?>
</legend>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/checkbox', ['label' => I18N::translate('Visible to other users when online'), 'name' => 'visible-online', 'checked' => (bool) $user->getPreference(UserInterface::PREF_IS_VISIBLE_ONLINE)]) ?>
<div class="form-text" id="visible-online-description">
<?= I18N::translate('You can choose whether to appear in the list of users who are currently signed-in.') ?>
</div>
</div>
</fieldset>
<?php if ($tree instanceof Tree && Configuration::isEnabled($tree)) : ?>
<fieldset class="row">
<legend class="col-sm-3 col-form-label wt-page-options-label">
<?= I18N::translate('Newsletter subscription') ?>
</legend>
<div class="col-sm-9 wt-page-options-value">
<?= view('components/checkbox', [
'label' => I18N::translate('Subscribe to the newsletter'),
'name' => 'newsletter_subscribed',
'checked' => $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1',
]) ?>
<div class="form-text">
<?= I18N::translate('You will receive a periodic email with upcoming birthdays and other family events from %s.', e($tree->title())) ?>
</div>
</div>
</fieldset>
<?php endif ?>
<div class="row mb-3">
<div class="col-sm-3 wt-page-options-label"></div>
<div class="col-sm-9 wt-page-options-value">
<button type="submit" class="btn btn-primary">
<?= view('icons/save') ?>
<?= I18N::translate('save') ?>
</button>
</div>
</div>
<?= csrf_field() ?>
</form>
<?php if ($show_delete_option) : ?>
<div class="row mb-3">
<div class="col-sm-3 wt-page-options-label"></div>
<div class="col-sm-9 wt-page-options-value">
<a href="#" class="btn btn-danger" data-wt-confirm="<?= I18N::translate('Are you sure you want to delete “%s”?', e($user->userName())) ?>" data-wt-post-url="<?= e(route(AccountDelete::class)) ?>">
<?= view('icons/delete') ?>
<?= I18N::translate('Delete your account') ?>
</a>
</div>
</div>
<?php endif ?>
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
use Fisharebest\Webtrees\Date;
use Fisharebest\Webtrees\Fact;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Tree;
use Illuminate\Support\Collection;
/**
* @var Tree $tree
* @var Collection<int,Fact> $birthdays
* @var Collection<int,Fact>|null $anniversaries
* @var Collection<int,Fact>|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));
};
?><!doctype html>
<html lang="<?= e(I18N::languageTag()) ?>">
<head>
<meta charset="utf-8">
<title><?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?></title>
</head>
<body style="font-family: Helvetica, Arial, sans-serif; color: #222; max-width: 640px; margin: 0 auto;">
<h1 style="border-bottom: 2px solid #888; padding-bottom: 0.3rem;">
<?= e($tree->title()) ?>
</h1>
<p style="color: #555;">
<?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
</p>
<?php if (!$birthdays->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
<ul>
<?php foreach ($birthdays as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($birthday_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
<ul>
<?php foreach ($anniversaries as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($anniversary_label($age)) ?>
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
<h2 style="color: #663;"><?= e(I18N::translate('On this month in history')) ?></h2>
<p style="color: #666;">
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
</p>
<ul>
<?php foreach ($historical as $fact) : ?>
<li>
<strong><?= e($record_label($fact)) ?></strong>
<?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
<hr style="margin-top: 2rem; border: 0; border-top: 1px solid #ccc;">
<p style="color: #888; font-size: 0.85rem;">
<?= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
<br>
<?= I18N::translate(
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
'<a href="' . e($account_url) . '" style="color: #336;">' . e(I18N::translate('My account')) . '</a>',
) ?>
</p>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter;
use Fisharebest\Webtrees\Tree;
/**
* Per-tree configuration for the Email Newsletter module.
*
* All values are persisted via $tree->setPreference()/getPreference().
* State values (last-sent timestamp, last historical-section month) are
* also stored on the tree because each tree produces its own newsletter.
*/
final class Configuration
{
// webtrees stores all *_setting tables with a varchar(32) setting_name
// column. Keys here MUST stay <= 32 characters.
public const string PREF_ENABLED = 'NEWSLETTER_ENABLED';
public const string PREF_FREQUENCY_DAYS = 'NEWSLETTER_FREQ_DAYS';
public const string PREF_LOOKAHEAD_DAYS = 'NEWSLETTER_LOOK_DAYS';
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT';
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
// Module-level (not tree-bound) settings.
public const string MODULE_PREF_CRON_TOKEN = 'cron_token';
// Per-user subscription preference. Set via $user->setPreference().
public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed';
public const int DEFAULT_FREQUENCY_DAYS = 14;
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
public const int MIN_FREQUENCY_DAYS = 1;
public const int MAX_FREQUENCY_DAYS = 90;
public const int MIN_LOOKAHEAD_DAYS = 1;
public const int MAX_LOOKAHEAD_DAYS = 60;
public static function isEnabled(Tree $tree): bool
{
return $tree->getPreference(self::PREF_ENABLED) === '1';
}
public static function frequencyDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_FREQUENCY_DAYS, (string) self::DEFAULT_FREQUENCY_DAYS);
return max(self::MIN_FREQUENCY_DAYS, min(self::MAX_FREQUENCY_DAYS, $value));
}
public static function lookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(self::PREF_LOOKAHEAD_DAYS, (string) self::DEFAULT_LOOKAHEAD_DAYS);
return max(self::MIN_LOOKAHEAD_DAYS, min(self::MAX_LOOKAHEAD_DAYS, $value));
}
public static function includeAnniversaries(Tree $tree): bool
{
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
}
public static function historicalLookaheadDays(Tree $tree): int
{
$value = (int) $tree->getPreference(
self::PREF_HISTORICAL_LOOKAHEAD,
(string) self::DEFAULT_HISTORICAL_LOOKAHEAD,
);
return max(7, min(60, $value));
}
public static function subjectPrefix(Tree $tree): string
{
return $tree->getPreference(self::PREF_SUBJECT_PREFIX, '[' . $tree->title() . '] ');
}
/**
* Extra recipient email addresses configured by the admin (one per line).
*
* @return array<int,string>
*/
public static function extraRecipients(Tree $tree): array
{
$raw = $tree->getPreference(self::PREF_EXTRA_RECIPIENTS, '');
$lines = preg_split('/\R/', $raw) ?: [];
$lines = array_map('trim', $lines);
return array_values(array_filter($lines, static fn (string $line): bool => $line !== ''));
}
public static function lastSentAt(Tree $tree): int
{
return (int) $tree->getPreference(self::PREF_LAST_SENT_AT, '0');
}
public static function setLastSentAt(Tree $tree, int $timestamp): void
{
$tree->setPreference(self::PREF_LAST_SENT_AT, (string) $timestamp);
}
public static function lastHistoricalMonth(Tree $tree): string
{
return $tree->getPreference(self::PREF_LAST_HISTORICAL_MONTH, '');
}
public static function setLastHistoricalMonth(Tree $tree, string $yearMonth): void
{
$tree->setPreference(self::PREF_LAST_HISTORICAL_MONTH, $yearMonth);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Http;
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Fisharebest\Webtrees\Validator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Wraps the built-in AccountUpdate handler and adds persistence of the
* "Subscribe to the newsletter" checkbox we inject into the
* edit-account-page view.
*
* The webtrees AccountUpdate class is final, so we decorate it rather
* than subclassing it. The wrapped handler runs first (so the user's
* own changes go through the normal validation path); afterwards we
* persist the per-tree subscription flag.
*
* This handler is bound in place of AccountUpdate via the webtrees DI
* container — see EmailNewsletter\Module::boot().
*/
final class AccountUpdateDecorator implements RequestHandlerInterface
{
public function __construct(
private readonly AccountUpdate $inner,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$response = $this->inner->handle($request);
$tree = Validator::attributes($request)->treeOptional();
$user = Validator::attributes($request)->user();
if ($tree instanceof Tree && $user instanceof User) {
$subscribed = Validator::parsedBody($request)
->boolean('newsletter_subscribed', false);
$tree->setUserPreference(
$user,
Configuration::USER_PREF_SUBSCRIBED,
$subscribed ? '1' : '0',
);
}
return $response;
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
/**
* Email Newsletter module for webtrees.
*
* @license AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace EmailNewsletter;
use EmailNewsletter\Http\AccountUpdateDecorator;
use EmailNewsletter\Services\NewsletterDispatchService;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\FlashMessages;
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Menu;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\View;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
{
use ModuleCustomTrait;
use ModuleConfigTrait;
use ModuleMenuTrait;
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
public function __construct(
private readonly NewsletterDispatchService $dispatch_service,
private readonly TreeService $tree_service,
) {
}
public function title(): string
{
return I18N::translate('Email Newsletter');
}
public function description(): string
{
return I18N::translate('Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.');
}
public function customModuleAuthorName(): string
{
return 'Alex';
}
public function customModuleVersion(): string
{
return '1.0.0';
}
public function customModuleSupportUrl(): string
{
return '';
}
public function resourcesFolder(): string
{
return __DIR__ . '/../resources/';
}
public function boot(): void
{
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
// Inject our newsletter-subscription field into the built-in
// /my-account page and persist it via a decorated handler.
View::registerCustomView('::edit-account-page', $this->name() . '::edit-account-page');
$container = Registry::container();
$container->set(
AccountUpdate::class,
new AccountUpdateDecorator($container->get(AccountUpdate::class)),
);
}
public function customTranslations(string $language): array
{
$translations = [
'de' => [
'Email Newsletter' => 'E-Mail-Newsletter',
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
=> 'Versendet regelmäßige Newsletter mit anstehenden Geburtstagen, Hochzeitstagen und historischen Ereignissen.',
'Family newsletter — %s' => 'Familien-Newsletter — %s',
'Upcoming birthdays' => 'Anstehende Geburtstage',
'Upcoming marriage anniversaries' => 'Anstehende Hochzeitstage',
'On this month in history' => 'In diesem Monat in der Geschichte',
'Newsletter subscription' => 'Newsletter-Abonnement',
'Subscribe to the newsletter' => 'Newsletter abonnieren',
'Send newsletters every' => 'Newsletter senden alle',
'days' => 'Tage',
'Look ahead' => 'Vorschau',
'Include marriage anniversaries' => 'Hochzeitstage einbeziehen',
'Historical look-ahead (days)' => 'Historische Vorschau (Tage)',
'Extra recipient email addresses (one per line)' => 'Zusätzliche Empfänger-E-Mail-Adressen (eine pro Zeile)',
'Subject prefix' => 'Betreff-Präfix',
'Save' => 'Speichern',
'Send now' => 'Jetzt senden',
'Cron token' => 'Cron-Token',
'Regenerate token' => 'Token neu generieren',
'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.',
'%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag',
'Wedding anniversary' => 'Hochzeitstag',
'Events in the next %d days.' => 'Ereignisse in den nächsten %d Tagen.',
'Events in the next %d days for people who have passed away.'
=> 'Ereignisse in den nächsten %d Tagen für bereits verstorbene Personen.',
'%d years old' => '%d Jahre alt',
'%d years' => '%d Jahre',
'You will receive a periodic email with upcoming birthdays and other family events from %s.'
=> 'Sie erhalten regelmäßig eine E-Mail mit anstehenden Geburtstagen und weiteren Familienereignissen aus %s.',
'You are receiving this email because you subscribed to the %s newsletter.'
=> 'Sie erhalten diese E-Mail, weil Sie den Newsletter „%s“ abonniert haben.',
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.'
=> 'Um Ihr Abonnement zu ändern oder zu kündigen, bearbeiten Sie den Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s.',
'Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).'
=> 'Newsletter-Versand pro Stammbaum konfigurieren. Absender ist die Kontaktperson des jeweiligen Baums (alternativ der Webmaster der Seite).',
'Enable newsletter for this tree' => 'Newsletter für diesen Baum aktivieren',
'Only intact marriages of still-living couples are included.'
=> 'Nur bestehende Ehen lebender Paare werden berücksichtigt.',
'Births and deaths of deceased people are included once per calendar month.'
=> 'Geburten und Todestage verstorbener Personen werden einmal pro Kalendermonat einbezogen.',
'Last sent: %s' => 'Zuletzt gesendet: %s',
'Configure your system cron, systemd timer, or any external scheduler to call the URL below. The schedule decides when newsletters are actually due — calling more frequently is safe.'
=> 'Richten Sie System-Cron, systemd-Timer oder einen externen Scheduler so ein, dass er die untenstehende URL aufruft. Der Versandplan entscheidet, wann tatsächlich gesendet wird — häufiger aufrufen ist unbedenklich.',
'Send the newsletter now for every enabled tree?'
=> 'Newsletter jetzt für jeden aktivierten Baum senden?',
],
'nl' => [
'Email Newsletter' => 'E-mailnieuwsbrief',
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
=> 'Verstuurt periodieke nieuwsbrieven met aankomende verjaardagen, trouwdagen en historische gebeurtenissen.',
'Family newsletter — %s' => 'Familienieuwsbrief — %s',
'Upcoming birthdays' => 'Aankomende verjaardagen',
'Upcoming marriage anniversaries' => 'Aankomende trouwdagen',
'On this month in history' => 'Deze maand in de geschiedenis',
'Newsletter subscription' => 'Nieuwsbriefabonnement',
'Subscribe to the newsletter' => 'Abonneren op de nieuwsbrief',
'Send newsletters every' => 'Nieuwsbrieven verzenden elke',
'days' => 'dagen',
'Look ahead' => 'Vooruitkijken',
'Include marriage anniversaries' => 'Trouwdagen meenemen',
'Historical look-ahead (days)' => 'Historische vooruitblik (dagen)',
'Extra recipient email addresses (one per line)' => 'Extra ontvanger-e-mailadressen (één per regel)',
'Subject prefix' => 'Onderwerpvoorvoegsel',
'Save' => 'Opslaan',
'Send now' => 'Nu verzenden',
'Cron token' => 'Cron-token',
'Regenerate token' => 'Token opnieuw genereren',
'Your subscription has been updated.' => 'Uw abonnement is bijgewerkt.',
],
];
return $translations[$language] ?? [];
}
// ─── Menu ────────────────────────────────────────────────────────
public function defaultMenuOrder(): int
{
return 99;
}
public function getMenu(Tree $tree): Menu|null
{
if (!Auth::check()) {
return null;
}
if (!Configuration::isEnabled($tree)) {
return null;
}
return new Menu(
I18N::translate('Newsletter subscription'),
route(AccountEdit::class, ['tree' => $tree->name()]),
'menu-newsletter-subscription',
);
}
// ─── Admin config page ──────────────────────────────────────────
public function getAdminAction(): ResponseInterface
{
$this->layout = 'layouts/administration';
return $this->viewResponse($this->name() . '::admin', [
'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'),
'module' => $this,
'all_trees' => $this->tree_service->all(),
'cron_token' => $this->cronToken(),
'cron_url' => $this->cronUrl(),
]);
}
public function postAdminAction(ServerRequestInterface $request): ResponseInterface
{
foreach ($this->tree_service->all() as $tree) {
$id = $tree->id();
$enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1';
$frequency = Validator::parsedBody($request)
->isBetween(Configuration::MIN_FREQUENCY_DAYS, Configuration::MAX_FREQUENCY_DAYS)
->integer('frequency-' . $id, Configuration::DEFAULT_FREQUENCY_DAYS);
$lookahead = Validator::parsedBody($request)
->isBetween(Configuration::MIN_LOOKAHEAD_DAYS, Configuration::MAX_LOOKAHEAD_DAYS)
->integer('lookahead-' . $id, Configuration::DEFAULT_LOOKAHEAD_DAYS);
$histLook = Validator::parsedBody($request)
->isBetween(7, 60)
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
$subject = Validator::parsedBody($request)->string('subject-' . $id, '');
$tree->setPreference(Configuration::PREF_ENABLED, $enabled ? '1' : '0');
$tree->setPreference(Configuration::PREF_FREQUENCY_DAYS, (string) $frequency);
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
$tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
if ($subject !== '') {
$tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject);
}
}
if (Validator::parsedBody($request)->string('regenerate_token', '0') === '1') {
$this->setPreference(self::SETTING_CRON_TOKEN, $this->generateToken());
}
FlashMessages::addMessage(
I18N::translate('The preferences for the module “%s” have been updated.', $this->title()),
'success',
);
return redirect($this->getConfigLink());
}
/**
* Admin-triggered immediate run (bypasses the "due" check, hits all
* enabled trees). Useful for testing without waiting for the timer.
*/
public function postSendNowAdminAction(ServerRequestInterface $request): ResponseInterface
{
$log = $this->dispatch_service->dispatch($this, true);
foreach ($log as $line) {
FlashMessages::addMessage($line, 'info');
}
return redirect($this->getConfigLink());
}
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
/**
* Triggered by an external timer (system cron, systemd timer, etc.)
* via a plain HTTP GET. Authentication is by shared-secret token —
* compared in constant time. No webtrees session is required.
*
* Never invoked by ordinary website traffic.
*/
public function getCronAction(ServerRequestInterface $request): ResponseInterface
{
$supplied = Validator::queryParams($request)->string('token', '');
$expected = $this->cronToken();
if ($expected === '' || !hash_equals($expected, $supplied)) {
throw new HttpAccessDeniedException('Invalid cron token');
}
$force = Validator::queryParams($request)->string('force', '0') === '1';
$log = $this->dispatch_service->dispatch($this, $force);
return response(
"Newsletter dispatch run\n" . implode("\n", $log) . "\n",
)->withHeader('Content-Type', 'text/plain; charset=utf-8');
}
// ─── Helpers ────────────────────────────────────────────────────
private function cronToken(): string
{
$token = $this->getPreference(self::SETTING_CRON_TOKEN);
if ($token === '') {
$token = $this->generateToken();
$this->setPreference(self::SETTING_CRON_TOKEN, $token);
}
return $token;
}
private function generateToken(): string
{
return bin2hex(random_bytes(24));
}
private function cronUrl(): string
{
return route('module', [
'module' => $this->name(),
'action' => 'Cron',
'token' => $this->cronToken(),
]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\CalendarService;
use Fisharebest\Webtrees\Tree;
use Illuminate\Support\Collection;
/**
* Collects the events that should appear in the newsletter.
*
* Wraps CalendarService and adds the project-specific filtering rules
* (living vs. deceased, "marriage still active", historical section).
*/
final class EventQueryService
{
public function __construct(
private readonly CalendarService $calendar_service,
) {
}
/**
* Birthdays of still-living individuals in the upcoming window.
*
* Window is [tomorrow, tomorrow + days - 1] so we never re-announce
* a birthday on the very day of the next scheduled send.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingBirthdays(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
return $this->calendar_service
->getEventsList($start, $end, 'BIRT', true, 'anniv', $tree);
}
/**
* Marriage anniversaries of intact couples — both spouses still
* living, and no divorce/annulment fact recorded.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingAnniversaries(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
$facts = $this->calendar_service
->getEventsList($start, $end, 'MARR', true, 'anniv', $tree);
// CalendarService already filtered out families where either
// spouse is dead. We additionally drop families that have a
// DIV, DIVF or ANUL fact recorded.
return $facts->filter(static function ($fact): bool {
$record = $fact->record();
if (!$record instanceof Family) {
return false;
}
foreach ($record->facts(['DIV', 'DIVF', 'ANUL']) as $end_fact) {
if ($end_fact->canShow()) {
return false;
}
}
return true;
})->values();
}
/**
* Births and deaths of deceased individuals in the upcoming window.
* Used for the once-per-month historical section.
*
* @return Collection<int,\Fisharebest\Webtrees\Fact>
*/
public function upcomingHistoricalEvents(Tree $tree, int $days): Collection
{
[$start, $end] = $this->window($days);
$facts = $this->calendar_service
->getEventsList($start, $end, 'BIRT|DEAT', false, 'anniv', $tree);
return $facts->filter(static function ($fact): bool {
$record = $fact->record();
return $record instanceof Individual && $record->isDead();
})->values();
}
/**
* @return array{0:int,1:int} Julian day numbers for the window.
*/
private function window(int $days): array
{
$now = Registry::timestampFactory()->now();
$start = $now->addDays(1)->julianDay();
$end = $now->addDays($days)->julianDay();
return [$start, $end];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use Fisharebest\Webtrees\Contracts\UserInterface;
/**
* Minimal UserInterface implementation for admin-added recipients that
* aren't backed by a webtrees user account. EmailService::send accepts
* any UserInterface, so this lets us reuse the same pipeline for both
* subscribed users and external addresses.
*/
final class ExtraRecipient implements UserInterface
{
public function __construct(
private readonly string $email,
private readonly string $real_name = '',
) {
}
public function id(): int
{
return 0;
}
public function email(): string
{
return $this->email;
}
public function realName(): string
{
return $this->real_name !== '' ? $this->real_name : $this->email;
}
public function userName(): string
{
return $this->email;
}
public function getPreference(string $setting_name, string $default = ''): string
{
return $default;
}
public function setPreference(string $setting_name, string $setting_value): void
{
// External recipients have no preferences to persist.
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace EmailNewsletter\Services;
use EmailNewsletter\Configuration;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Log;
use Fisharebest\Webtrees\Module\ModuleInterface;
use Fisharebest\Webtrees\Services\EmailService;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Exception\RfcComplianceException;
/**
* Decides which trees are due, builds per-tree newsletters, and dispatches
* them via the webtrees EmailService.
*
* The "due" decision is based solely on stored timestamps and the
* configured frequency. It is intentionally independent of who triggered
* the run, so this is safe to call from an external cron without
* worrying about double-sends from concurrent invocations (idempotent
* within the same window, modulo a very small race the lock guards).
*/
final class NewsletterDispatchService
{
/**
* Anonymous "from" address fallback when the site has no contact user.
*/
private const string FROM_NAME = 'webtrees newsletter';
public function __construct(
private readonly EventQueryService $event_query_service,
private readonly EmailService $email_service,
private readonly TreeService $tree_service,
private readonly UserService $user_service,
) {
}
/**
* Run the dispatch cycle for every tree.
*
* @return array<int,string> Human-readable log lines describing what
* happened, suitable for the cron endpoint
* to return to the caller.
*/
public function dispatch(ModuleInterface $module, bool $force = false): array
{
$log = [];
$now = time();
foreach ($this->tree_service->all() as $tree) {
if (!Configuration::isEnabled($tree)) {
$log[] = sprintf('Tree "%s": disabled, skipping.', $tree->name());
continue;
}
$due_at = Configuration::lastSentAt($tree)
+ Configuration::frequencyDays($tree) * 86400;
if (!$force && $now < $due_at) {
$log[] = sprintf(
'Tree "%s": not due yet (next send in %d hours).',
$tree->name(),
(int) (($due_at - $now) / 3600),
);
continue;
}
$log[] = $this->dispatchForTree($tree, $module, $now);
}
return $log;
}
private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now): string
{
$include_anniversaries = Configuration::includeAnniversaries($tree);
$lookahead = Configuration::lookaheadDays($tree);
$historical_lookahead = Configuration::historicalLookaheadDays($tree);
$current_month = date('Y-m', $now);
$include_historical = Configuration::lastHistoricalMonth($tree) !== $current_month;
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $lookahead);
$anniversaries = $include_anniversaries
? $this->event_query_service->upcomingAnniversaries($tree, $lookahead)
: null;
$historical = $include_historical
? $this->event_query_service->upcomingHistoricalEvents($tree, $historical_lookahead)
: null;
// Suppress entirely empty newsletters so subscribers don't get
// a near-empty email on a slow fortnight.
$has_content = !$birthdays->isEmpty()
|| ($anniversaries !== null && !$anniversaries->isEmpty())
|| ($historical !== null && !$historical->isEmpty());
if (!$has_content) {
Configuration::setLastSentAt($tree, $now);
return sprintf('Tree "%s": nothing to report, send timestamp advanced.', $tree->name());
}
$recipients = $this->collectRecipients($tree);
if ($recipients === []) {
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
}
$from = $this->siteContact($tree);
$original_locale = I18N::languageTag();
$groups = $this->groupRecipientsByLanguage($recipients);
$sent = 0;
$failures = 0;
try {
foreach ($groups as $lang => $group) {
I18N::init($lang);
$subject = Configuration::subjectPrefix($tree) . I18N::translate(
'Family newsletter — %s',
date('F j, Y', $now),
);
$html = view($module->name() . '::email', [
'tree' => $tree,
'birthdays' => $birthdays,
'anniversaries' => $anniversaries,
'historical' => $historical,
'include_anniversaries' => $include_anniversaries,
'include_historical' => $include_historical,
'lookahead_days' => $lookahead,
'historical_lookahead' => $historical_lookahead,
'generated_at' => $now,
'account_url' => route(
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
['tree' => $tree->name()],
),
]);
$text = $this->htmlToText($html);
foreach ($group as $recipient) {
try {
if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) {
$sent++;
} else {
$failures++;
}
} catch (RfcComplianceException | TransportExceptionInterface $ex) {
$failures++;
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
}
}
}
} finally {
// Always restore the original locale, even if a render or
// send throws an unexpected exception.
I18N::init($original_locale);
}
Configuration::setLastSentAt($tree, $now);
if ($include_historical) {
Configuration::setLastHistoricalMonth($tree, $current_month);
}
return sprintf(
'Tree "%s": sent to %d recipient(s), %d failure(s)%s.',
$tree->name(),
$sent,
$failures,
$include_historical ? ', monthly historical section included' : '',
);
}
/**
* Group recipients by the language we will render their email in.
* German users get "de"; everyone else (including admin-added
* external addresses) gets "en-US" for now.
*
* @param array<int,UserInterface> $recipients
* @return array<string,array<int,UserInterface>>
*/
private function groupRecipientsByLanguage(array $recipients): array
{
$groups = [];
foreach ($recipients as $recipient) {
$pref = $recipient->getPreference(UserInterface::PREF_LANGUAGE, '');
$lang = str_starts_with(strtolower($pref), 'de') ? 'de' : 'en-US';
$groups[$lang][] = $recipient;
}
return $groups;
}
/**
* @return array<int,UserInterface>
*/
private function collectRecipients(Tree $tree): array
{
$recipients = [];
$seen = [];
foreach ($this->user_service->all() as $user) {
if (!$user instanceof User) {
continue;
}
// Only deliver to approved + email-verified accounts.
if ($user->getPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
continue;
}
if ($user->getPreference(UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
continue;
}
if ($tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) !== '1') {
continue;
}
$email = trim($user->email());
if ($email === '' || isset($seen[strtolower($email)])) {
continue;
}
$seen[strtolower($email)] = true;
$recipients[] = $user;
}
foreach (Configuration::extraRecipients($tree) as $email) {
$key = strtolower($email);
if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) {
continue;
}
$seen[$key] = true;
$recipients[] = new ExtraRecipient($email);
}
return $recipients;
}
/**
* Pick a sender identity. Fall back to a synthetic UserInterface if
* the site has no contact user configured for this tree.
*/
private function siteContact(Tree $tree): UserInterface
{
$contact_id = (int) $tree->getPreference('CONTACT_USER_ID');
if ($contact_id !== 0) {
$user = $this->user_service->find($contact_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
$webmaster_id = (int) \Fisharebest\Webtrees\Site::getPreference('WEBMASTER_USER_ID');
if ($webmaster_id !== 0) {
$user = $this->user_service->find($webmaster_id);
if ($user !== null && trim($user->email()) !== '') {
return $user;
}
}
// Last resort: derive a sender address from the server hostname.
// The mail transport must accept this; admins will normally have
// a contact user set, so this only matters for fresh installs.
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$address = 'no-reply@' . preg_replace('/[^a-zA-Z0-9.\-]/', '', $host);
return new ExtraRecipient($address, self::FROM_NAME);
}
private function htmlToText(string $html): string
{
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
$without_tags = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $without_tags) ?? $without_tags;
$stripped = strip_tags($without_tags);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Collapse runs of blank lines.
return trim(preg_replace("/\n{3,}/", "\n\n", $decoded) ?? $decoded);
}
}