Initial commit: webtrees Email Newsletter module
Recurring email newsletter for webtrees 2.2+. Each enabled tree
sends upcoming birthdays of living individuals, optional marriage
anniversaries of intact couples, and a once-per-calendar-month
historical section of births and deaths of deceased individuals.
Triggered exclusively by an external scheduler (system cron,
systemd timer, etc.) hitting a token-gated HTTP endpoint — never
on visitor page loads. The "is it due?" decision is idempotent
within the configured frequency window.
Per-user subscription is integrated into the built-in
/my-account/{tree} page via a custom view + a decorated
AccountUpdate handler. Admins can add external addresses and
trigger an immediate send for testing. Email body renders in
German for German-language users; English otherwise. Birthdays
and anniversaries are formatted with the upcoming-event ordinal
age (e.g. "45th birthday" / "45. Geburtstag").
This commit is contained in:
@@ -0,0 +1,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>
|
||||
Reference in New Issue
Block a user