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
+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>