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
+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);
}
}