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