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>