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,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];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user