Per-user intro versioning + admin pending-delivery view

Replaces "clear intro after first send", which dropped the message
for any subscriber still queued on a slower cadence.

- Each non-empty admin save bumps a per-locale version counter on
  the tree. The dispatcher includes the intro only for recipients
  whose last-seen version is behind, then advances their watermark
  after a successful send. Webtrees users get a per-user watermark;
  external addresses share one tree-level watermark. Re-saving the
  same text is a no-op.
- The preferences page now shows delivery progress per locale:
  "Delivered to X of Y subscriber(s)" plus a collapsible Pending
  list with name + email of each subscriber who hasn't received
  the current intro yet, and a single "External recipients (N)"
  row when the external watermark is behind.
- README rewritten to reflect every feature shipped since the
  initial commit (BockenTheme skin, embedded avatars, relationship
  labels, kin-distance filter, per-user cadence, bilingual subject
  prefix, locale-aware subject date, SiteUser as From, three
  subscriber sources, Markdown intro with personalisation tokens).
This commit is contained in:
2026-05-15 15:57:16 +02:00
parent 9458867d4d
commit 90ad060421
5 changed files with 326 additions and 69 deletions
+115 -46
View File
@@ -3,35 +3,73 @@
A [webtrees](https://www.webtrees.net/) 2.2+ custom module that sends recurring A [webtrees](https://www.webtrees.net/) 2.2+ custom module that sends recurring
email newsletters with: email newsletters with:
- **Upcoming birthdays** of still-living individuals. - **Upcoming birthdays** of still-living individuals (formatted as ordinal
- **Upcoming marriage anniversaries** of intact couples (optional — admin age — *"45th birthday"* / *"45. Geburtstag"*).
toggle, per tree). Marriages with a divorce or annulment fact are excluded - **Upcoming marriage anniversaries** of intact couples (admin toggle, per
tree). Marriages with a divorce or annulment fact are excluded
automatically. automatically.
- **Once-per-month historical section**: births and deaths of deceased - **Historical events** — births and deaths of deceased individuals whose
individuals whose anniversary falls in the upcoming window. anniversary falls in the upcoming window.
The decision to actually send is made by comparing a stored "last sent" The look-ahead window and the send cadence are the same number: one
timestamp to the configured frequency, so the dispatch run is idempotent — "every N days" setting (default 14) drives both the cron interval and how
calling the trigger more often than the frequency simply does nothing far each issue looks ahead. Issues with nothing to report are silently
extra. skipped.
Each recipient gets a per-recipient render — language, relationship
labels, detail filter, cadence, and personalisation tokens are all
resolved against *their* webtrees account.
## Highlights
- **Editorial layout** with embedded circular avatars, a left-side
timeline rail, and event-type icons (birth / death / marriage).
- **BockenTheme light-mode skin** — Open Sans, cream background, Nord
accent palette. The newsletter and the website read as one product.
- **Per-recipient localisation** — German for users whose webtrees
language starts with `de`, English otherwise. Subject line, body,
date strings, and (optionally) a custom subject prefix are all
localised. Subject dates use `IntlDateFormatter` for the recipient's
locale.
- **Per-recipient relationship labels** — *"your mother"*, *"4th great-
grandfather"*, *"first cousin twice removed"*. Uses webtrees' own
`RelationshipService` so the labels match the site.
- **Kin-distance detail filter** — close family get the full card
(avatar + timeline + icon); distant kin appear as a single-line
bullet at the foot of each section. The "distance" radius is an
admin setting (default 3); spouses inherit their partner's distance;
recipients with no linked tree record always see the full detailed
view.
- **Per-recipient cadence** — each subscriber can pick weekly,
biweekly, monthly, every-two-months, quarterly, or "use site
default" on their `/my-account/{tree}` page.
- **One-shot intro paragraph** — admins can attach a Markdown intro
(bilingual, EN/DE) to the next issue. Supports
`{{first_name}}`, `{{last_name}}`, `{{username}}`, `{{email}}`
personalisation tokens. Rendered alongside the tree-contact user's
avatar as an editorial column. Cleared automatically after a
successful send.
- **Cron-only dispatch** — the "is it due?" decision is made server-
side against stored timestamps. Calling the trigger more often than
the cadence is harmless and idempotent.
## Requirements ## Requirements
- webtrees ≥ 2.2.0 - webtrees ≥ 2.2.0
- PHP ≥ 8.2 - PHP ≥ 8.2 with `ext-intl` (for locale-aware subject dates) and
- A working SMTP / sendmail configuration in *webtrees → Control panel → either `ext-imagick` or `ext-gd` (for avatar resizing — falls back
Sending email* (this module reuses webtrees' standard mailer). to original-size embeds if neither is present).
- An external scheduler on the host: system `cron`, a `systemd` timer, - A working SMTP / sendmail configuration in *Control panel → Sending
a Kubernetes `CronJob`, or anything else that can fire an HTTP request email*. This module reuses webtrees' standard mailer and signs with
at a fixed interval. **Newsletter dispatch never runs on visitor page the site's DKIM keys if configured.
loads — it only runs when the scheduler triggers it.** - An external scheduler on the host (system `cron`, `systemd` timer,
Kubernetes `CronJob`, …) that can fire an HTTP request at a fixed
interval. **Newsletter dispatch never runs on visitor page loads.**
## Installation ## Installation
1. Copy this directory into the webtrees `modules_v4/` folder, renaming 1. Copy this directory into the webtrees `modules_v4/` folder, renaming
it to `email_newsletter` (the folder name determines the internal it to `email_newsletter`:
module identifier — the registered name will be
`_email_newsletter_`).
```sh ```sh
cp -r webtrees_email_newsletter /var/www/webtrees/modules_v4/email_newsletter cp -r webtrees_email_newsletter /var/www/webtrees/modules_v4/email_newsletter
@@ -40,20 +78,33 @@ extra.
2. In the webtrees control panel, go to *Modules → All modules* and 2. In the webtrees control panel, go to *Modules → All modules* and
enable **Email Newsletter**. enable **Email Newsletter**.
3. Open *Control panel → Modules → Email Newsletter → Preferences* and: 3. Open *Control panel → Modules → Email Newsletter → Preferences*
- Enable newsletter dispatch per tree. and, for each tree:
- Pick a frequency (default: 14 days).
- Optionally toggle marriage anniversaries and add any extra - Tick *Enable newsletter for this tree*.
external email addresses. - Set the send-cadence (default 14 days). This same number is the
- Copy the **Cron URL** at the bottom — this is the secret-token look-ahead window for the next issue.
URL your scheduler must hit. - Toggle *Include marriage anniversaries* if desired.
- Set *Detailed view distance* (default 3). Lower values produce a
terser email focused tightly on close kin.
- Optional: set per-locale *Subject prefix* and a *Generic*
fallback (e.g. `[Bocken family] `).
- Optional: tick existing webtrees users in *Subscribed users* to
subscribe them. Users can still adjust their own subscription
and cadence on `/my-account/{tree}`.
- Optional: add external (non-user) addresses in *Extra recipient
email addresses*.
4. Copy the **Cron URL** at the bottom and wire it into your scheduler
(see below).
## Setting up the scheduler ## Setting up the scheduler
> **Why no built-in scheduler?** PHP has no daemon, and frameworks like > **Why no built-in scheduler?** PHP has no daemon, and frameworks
> Laravel rely on a once-per-minute system cron to fire their internal > like Laravel rely on a once-per-minute system cron to fire their
> scheduler. This module follows the same convention: the host OS owns > internal scheduler. This module follows the same convention: the
> the timer, the module owns the "is it actually due?" decision. > host OS owns the timer, the module owns the "is it actually due?"
> decision.
### System cron ### System cron
@@ -94,29 +145,47 @@ Then `systemctl enable --now webtrees-newsletter.timer`.
### Forcing a one-off send ### Forcing a one-off send
The admin **Preferences** page has a *Send now* button for testing. The admin **Preferences** page has a *Send now* button for testing.
For an unattended one-off send, append `&force=1` to the cron URL — For an unattended forced send (bypassing the per-recipient "is it
that bypasses the "is it due?" check. due?" check), append `&force=1` to the cron URL.
## Subscribers ## Subscribers
Two sources, combined: Three sources, combined and de-duplicated by email:
1. **Logged-in webtrees users** who opt in via the per-tree 1. **Webtrees users** who opt in themselves via the per-tree
*Newsletter subscription* menu entry (visible only to logged-in *Newsletter subscription* menu entry on `/my-account/{tree}` —
users on trees where the module is enabled). Only **approved and visible only to logged-in users on trees where the module is
email-verified** accounts will receive the newsletter. enabled.
2. **External addresses** the tree administrator lists in the 2. **Webtrees users** an admin subscribes from the preferences page.
*Extra recipient email addresses* textarea (one per line). 3. **External addresses** the admin lists in *Extra recipient email
addresses*.
Only **approved and email-verified** webtrees accounts will receive
the newsletter. External addresses always receive on every run
(they have no per-user cadence timer).
## Sender identity
To match webtrees' own convention for system-generated email
(registration, password resets, "new version available"), the
`From:` header is **SiteUser** — *Control panel → Sending email →
Sender name / Sender email* (`SMTP_FROM_NAME` / `SMTP_DISP_NAME`).
The tree's contact user becomes the `Reply-To:`, so replies still
reach a human admin.
If `SMTP_FROM_NAME` isn't set the dispatcher falls back to the tree
contact for `From:` as well, so the message always has a valid
sender envelope.
## Privacy ## Privacy
The dispatch service does not impersonate a webtrees user, so it sees The dispatch service does not impersonate a webtrees user, so it
the tree from the **visitor** access level. Records and facts that sees the tree from the **visitor** access level. Records and facts
your tree settings hide from visitors will be omitted from the that your tree settings hide from visitors will be omitted from the
newsletter even if a recipient has higher in-app access. This is the newsletter even if a recipient has higher in-app access. This is
safest default for an outbound email — if you need to expose more the safest default for an outbound email — if you need to expose
information, relax the tree's visitor-access settings or hand-curate more information, relax the tree's visitor-access settings or
the *Extra recipient* list. hand-curate the *Extra recipient* list.
## License ## License
+79 -4
View File
@@ -173,7 +173,7 @@ use Illuminate\Support\Collection;
</legend> </legend>
<div class="col-sm-9"> <div class="col-sm-9">
<small class="form-text text-muted d-block mb-2"> <small class="form-text text-muted d-block mb-2">
<?= I18N::translate('Shown once, above the upcoming events. Cleared automatically after the next successful send.') ?> <?= I18N::translate('Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.') ?>
<br> <br>
<?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?> <?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?>
<br> <br>
@@ -183,17 +183,92 @@ use Illuminate\Support\Collection;
<code>{{username}}</code>, <code>{{username}}</code>,
<code>{{email}}</code> <code>{{email}}</code>
</small> </small>
<?php
// Subscribers for this tree — used by the
// per-locale "who has seen it?" block
// below. Same approved/verified gate that
// the dispatcher applies, so the admin's
// numbers match the actual run.
$tree_subscribers = $all_users->filter(static function (User $user) use ($tree): bool {
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
return false;
}
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
return false;
}
return $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
});
$external_addresses = Configuration::extraRecipients($tree);
?>
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?> <?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
<?php <?php
$field = 'intro-' . $id . '-' . $code; $field = 'intro-' . $id . '-' . $code;
$val = Configuration::introForLocale($module, $tree, $code); $val = Configuration::introForLocale($module, $tree, $code);
$current_v = Configuration::introVersion($module, $tree, $code);
$external_v = Configuration::externalIntroVersion($module, $tree, $code);
$locale_subs = $tree_subscribers->filter(static function (User $user) use ($code): bool {
$pref = $user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_LANGUAGE, '');
return Configuration::canonicalSubjectLocale($pref) === $code;
});
// Status counts — only meaningful once
// the intro has been bumped to v ≥ 1.
$seen_users = 0;
$pending = [];
foreach ($locale_subs as $user) {
if (Configuration::userIntroVersion($tree, $user, $code) >= $current_v) {
$seen_users++;
} else {
$pending[] = $user;
}
}
$externals_seen = $current_v > 0 && $external_v >= $current_v;
$externals_pending = $current_v > 0 && !$externals_seen && $external_addresses !== [];
$total = $locale_subs->count() + ($external_addresses === [] ? 0 : 1);
$done = $seen_users + ($externals_seen ? 1 : 0);
?> ?>
<div class="mb-2"> <div class="mb-3">
<label class="form-label small text-muted mb-1" for="<?= e($field) ?>"> <label class="form-label small text-muted mb-1" for="<?= e($field) ?>">
<?= e($label) ?> <?= e($label) ?>
</label> </label>
<textarea class="form-control" rows="6" <textarea class="form-control" rows="6"
id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea> id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea>
<?php if ($val !== '' && $current_v > 0) : ?>
<div class="small text-muted mt-1">
<?php if ($done === $total) : ?>
<span class="text-success">&check;</span>
<?= I18N::translate('Delivered to all %d subscriber(s).', $total) ?>
<?php else : ?>
<?= I18N::translate('Delivered to %1$d of %2$d subscriber(s).', $done, $total) ?>
<?php if ($pending !== [] || $externals_pending) : ?>
<details class="mt-1">
<summary class="text-muted" style="cursor:pointer;">
<?= I18N::translate('Pending') ?>
</summary>
<ul class="list-unstyled small mb-0 mt-1 ps-2">
<?php foreach ($pending as $user) : ?>
<li>
<span class="text-warning">&#9203;</span>
<?= e($user->realName()) ?>
<span class="text-muted">&lt;<?= e($user->email()) ?>&gt;</span>
</li>
<?php endforeach ?>
<?php if ($externals_pending) : ?>
<li>
<span class="text-warning">&#9203;</span>
<?= I18N::translate('External recipients (%d)', count($external_addresses)) ?>
</li>
<?php endif ?>
</ul>
</details>
<?php endif ?>
<?php endif ?>
</div>
<?php elseif ($val !== '' && $current_v === 0) : ?>
<div class="small text-muted mt-1">
<?= I18N::translate('Save to schedule delivery.') ?>
</div>
<?php endif ?>
</div> </div>
<?php endforeach ?> <?php endforeach ?>
</div> </div>
+72 -6
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace EmailNewsletter; namespace EmailNewsletter;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Module\AbstractModule; use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
/** /**
* Per-tree configuration for the Email Newsletter module. * Per-tree configuration for the Email Newsletter module.
@@ -68,6 +70,7 @@ final class Configuration
public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed'; public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed';
public const string USER_PREF_FREQUENCY_DAYS = 'NEWSLETTER_USER_FREQ_DAYS'; public const string USER_PREF_FREQUENCY_DAYS = 'NEWSLETTER_USER_FREQ_DAYS';
public const string USER_PREF_LAST_SENT_AT = 'NEWSLETTER_USER_LAST_SENT'; public const string USER_PREF_LAST_SENT_AT = 'NEWSLETTER_USER_LAST_SENT';
public const string USER_PREF_INTRO_SEEN_PREFIX = 'intro_seen_';
public const int DEFAULT_FREQUENCY_DAYS = 14; public const int DEFAULT_FREQUENCY_DAYS = 14;
public const int DEFAULT_LINEAL_DEPTH = 3; public const int DEFAULT_LINEAL_DEPTH = 3;
@@ -217,14 +220,67 @@ final class Configuration
} }
/** /**
* Wipe every locale's intro paragraph. Called by the dispatcher * Monotonic version counter for the per-locale intro paragraph.
* after a successful send so the next issue starts clean. * Bumped by `bumpIntroVersion()` when an admin saves a new
* non-empty intro that differs from the previous value. Each
* recipient has their own last-seen version (per-user for
* registered users, tree-level for external addresses) — the
* dispatcher compares the two and only includes the intro for
* recipients who are behind, so a single intro reaches every
* subscriber exactly once even when their cadences differ.
*/ */
public static function clearIntros(AbstractModule $module, Tree $tree): void public static function introVersion(AbstractModule $module, Tree $tree, string $language): int
{ {
foreach (array_keys(self::supportedSubjectLocales()) as $code) { $code = self::canonicalSubjectLocale($language);
$module->setPreference(self::introKey($tree, $code), '');
} return (int) $module->getPreference(self::introVersionKey($tree, $code), '0');
}
public static function bumpIntroVersion(AbstractModule $module, Tree $tree, string $language): int
{
$code = self::canonicalSubjectLocale($language);
$next = self::introVersion($module, $tree, $language) + 1;
$module->setPreference(self::introVersionKey($tree, $code), (string) $next);
return $next;
}
/**
* Last intro version a webtrees user has received, per tree per
* locale. Stored as a per-tree-per-user gedcom_setting — the
* setting name needs no tree suffix because
* `$tree->setUserPreference()` is already tree-scoped.
*/
public static function userIntroVersion(Tree $tree, UserInterface $user, string $language): int
{
$code = self::canonicalSubjectLocale($language);
return (int) $tree->getUserPreference($user, self::USER_PREF_INTRO_SEEN_PREFIX . $code, '0');
}
public static function setUserIntroVersion(Tree $tree, UserInterface $user, string $language, int $version): void
{
$code = self::canonicalSubjectLocale($language);
$tree->setUserPreference($user, self::USER_PREF_INTRO_SEEN_PREFIX . $code, (string) $version);
}
/**
* Last intro version delivered to any external (non-User) address
* for this tree + locale. Webtrees has nowhere to hang per-user
* state for these recipients, so we track a single tree-level
* watermark instead — externals see each intro at most once.
*/
public static function externalIntroVersion(AbstractModule $module, Tree $tree, string $language): int
{
$code = self::canonicalSubjectLocale($language);
return (int) $module->getPreference(self::externalSeenKey($tree, $code), '0');
}
public static function setExternalIntroVersion(AbstractModule $module, Tree $tree, string $language, int $version): void
{
$code = self::canonicalSubjectLocale($language);
$module->setPreference(self::externalSeenKey($tree, $code), (string) $version);
} }
private static function introKey(Tree $tree, string $locale_code): string private static function introKey(Tree $tree, string $locale_code): string
@@ -232,6 +288,16 @@ final class Configuration
return self::MODULE_PREF_INTRO_PREFIX . $tree->id() . '_' . $locale_code; return self::MODULE_PREF_INTRO_PREFIX . $tree->id() . '_' . $locale_code;
} }
private static function introVersionKey(Tree $tree, string $locale_code): string
{
return 'intro_v_' . $tree->id() . '_' . $locale_code;
}
private static function externalSeenKey(Tree $tree, string $locale_code): string
{
return 'intro_ext_' . $tree->id() . '_' . $locale_code;
}
/** /**
* Extra recipient email addresses configured by the admin (one per line). * Extra recipient email addresses configured by the admin (one per line).
* *
+17
View File
@@ -173,9 +173,16 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Intro paragraph for the next email' => 'Einleitungsabsatz für die nächste E-Mail', 'Intro paragraph for the next email' => 'Einleitungsabsatz für die nächste E-Mail',
'Shown once, above the upcoming events. Cleared automatically after the next successful send.' 'Shown once, above the upcoming events. Cleared automatically after the next successful send.'
=> 'Wird einmalig über den anstehenden Ereignissen angezeigt. Wird nach dem nächsten erfolgreichen Versand automatisch geleert.', => 'Wird einmalig über den anstehenden Ereignissen angezeigt. Wird nach dem nächsten erfolgreichen Versand automatisch geleert.',
'Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.'
=> 'Wird jedem Abonnenten einmalig in seinem eigenen Versandrhythmus zugestellt. Text bearbeiten und speichern, um eine neue Einleitung an alle zu versenden.',
'Personalisation tokens:' => 'Personalisierungs-Platzhalter:', 'Personalisation tokens:' => 'Personalisierungs-Platzhalter:',
'Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.' 'Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.'
=> 'Formatiert als Markdown — z. B. %1$s für Hervorhebung, %2$s für einen Link.', => 'Formatiert als Markdown — z. B. %1$s für Hervorhebung, %2$s für einen Link.',
'Delivered to all %d subscriber(s).' => 'An alle %d Abonnenten zugestellt.',
'Delivered to %1$d of %2$d subscriber(s).' => 'An %1$d von %2$d Abonnenten zugestellt.',
'Pending' => 'Ausstehend',
'External recipients (%d)' => 'Externe Empfänger (%d)',
'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.',
], ],
'nl' => [ 'nl' => [
'Email Newsletter' => 'E-mailnieuwsbrief', 'Email Newsletter' => 'E-mailnieuwsbrief',
@@ -293,7 +300,17 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
$intro = Validator::parsedBody($request) $intro = Validator::parsedBody($request)
->string('intro-' . $id . '-' . $code, ''); ->string('intro-' . $id . '-' . $code, '');
// Bump the version only when the saved text actually
// changed AND is non-empty. That makes "save the same
// intro again" a no-op (no resends), while saving a
// new non-empty paragraph re-delivers it to every
// subscriber on their own cadence.
$previous = Configuration::introForLocale($this, $tree, $code);
Configuration::setIntroForLocale($this, $tree, $code, $intro); Configuration::setIntroForLocale($this, $tree, $code, $intro);
if ($intro !== '' && $intro !== $previous) {
Configuration::bumpIntroVersion($this, $tree, $code);
}
} }
// Per-user subscription toggles. A users-roster marker is // Per-user subscription toggles. A users-roster marker is
+43 -13
View File
@@ -185,9 +185,14 @@ final class NewsletterDispatchService
// One-shot intro paragraph (admin-supplied). Empty // One-shot intro paragraph (admin-supplied). Empty
// string is "no intro" — the view simply omits the // string is "no intro" — the view simply omits the
// block. Cleared from preferences after the run if // block. We do NOT clear it after sending; instead
// at least one recipient was successfully reached. // we version-stamp it and track each recipient's
$intro = Configuration::introForLocale($module, $tree, $lang); // last-seen version, so subscribers on slower
// cadences still get the message exactly once.
$intro = Configuration::introForLocale($module, $tree, $lang);
$intro_version = Configuration::introVersion($module, $tree, $lang);
$external_seen = Configuration::externalIntroVersion($module, $tree, $lang);
$external_served_this_run = false;
// Render the email body per recipient — the relationship // Render the email body per recipient — the relationship
// labels are personalised relative to whichever individual // labels are personalised relative to whichever individual
@@ -221,6 +226,21 @@ final class NewsletterDispatchService
} }
} }
// Decide whether to attach the intro for *this*
// recipient: only if it's non-empty AND this
// recipient hasn't yet received the current
// version. Webtrees users have their own
// watermark; external addresses share a single
// tree-level one.
$recipient_seen = $recipient instanceof User
? Configuration::userIntroVersion($tree, $recipient, $lang)
: $external_seen;
$show_intro = $intro !== '' && $intro_version > $recipient_seen;
$personalised_intro = $show_intro
? $this->renderIntroTemplate($intro, $recipient)
: '';
$html = view($module->name() . '::email', [ $html = view($module->name() . '::email', [
'tree' => $tree, 'tree' => $tree,
'birthdays' => $birthdays, 'birthdays' => $birthdays,
@@ -233,8 +253,8 @@ final class NewsletterDispatchService
'relationships' => $relationships, 'relationships' => $relationships,
'detailed_xrefs' => $detailed_set, 'detailed_xrefs' => $detailed_set,
'account_url' => $account_url, 'account_url' => $account_url,
'intro' => $this->renderIntroTemplate($intro, $recipient), 'intro' => $personalised_intro,
'intro_author' => $intro_author, 'intro_author' => $show_intro ? $intro_author : null,
]); ]);
$text = $this->htmlToText($html); $text = $this->htmlToText($html);
@@ -247,6 +267,20 @@ final class NewsletterDispatchService
// added addresses always fire on every run. // added addresses always fire on every run.
if ($recipient instanceof User) { if ($recipient instanceof User) {
Configuration::setUserLastSentAt($tree, $recipient, $now); Configuration::setUserLastSentAt($tree, $recipient, $now);
// Mark this user as up-to-date on the
// intro so we don't re-deliver it next
// time their cadence comes round.
if ($show_intro) {
Configuration::setUserIntroVersion($tree, $recipient, $lang, $intro_version);
}
} elseif ($show_intro) {
// External recipient — bump the single
// tree-level "externals served"
// watermark after the loop so all
// externals in this run see the same
// intro before we move it forward.
$external_served_this_run = true;
} }
} else { } else {
$failures++; $failures++;
@@ -256,6 +290,10 @@ final class NewsletterDispatchService
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage()); Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
} }
} }
if ($external_served_this_run) {
Configuration::setExternalIntroVersion($module, $tree, $lang, $intro_version);
}
} }
} finally { } finally {
// Always restore the original locale, even if a render or // Always restore the original locale, even if a render or
@@ -269,14 +307,6 @@ final class NewsletterDispatchService
Configuration::setLastSentAt($tree, $now); Configuration::setLastSentAt($tree, $now);
} }
// One-shot intro: clear only once at least one recipient was
// actually reached. A run that produced nothing but failures
// (transport down, all addresses bouncing) preserves the
// admin's intro for the next attempt.
if ($sent > 0) {
Configuration::clearIntros($module, $tree);
}
return sprintf( return sprintf(
'Tree "%s": sent to %d recipient(s), %d failure(s).', 'Tree "%s": sent to %d recipient(s), %d failure(s).',
$tree->name(), $tree->name(),