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
+43 -13
View File
@@ -185,9 +185,14 @@ final class NewsletterDispatchService
// One-shot intro paragraph (admin-supplied). Empty
// string is "no intro" — the view simply omits the
// block. Cleared from preferences after the run if
// at least one recipient was successfully reached.
$intro = Configuration::introForLocale($module, $tree, $lang);
// block. We do NOT clear it after sending; instead
// we version-stamp it and track each recipient's
// 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
// 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', [
'tree' => $tree,
'birthdays' => $birthdays,
@@ -233,8 +253,8 @@ final class NewsletterDispatchService
'relationships' => $relationships,
'detailed_xrefs' => $detailed_set,
'account_url' => $account_url,
'intro' => $this->renderIntroTemplate($intro, $recipient),
'intro_author' => $intro_author,
'intro' => $personalised_intro,
'intro_author' => $show_intro ? $intro_author : null,
]);
$text = $this->htmlToText($html);
@@ -247,6 +267,20 @@ final class NewsletterDispatchService
// added addresses always fire on every run.
if ($recipient instanceof User) {
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 {
$failures++;
@@ -256,6 +290,10 @@ final class NewsletterDispatchService
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
}
}
if ($external_served_this_run) {
Configuration::setExternalIntroVersion($module, $tree, $lang, $intro_version);
}
}
} finally {
// Always restore the original locale, even if a render or
@@ -269,14 +307,6 @@ final class NewsletterDispatchService
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(
'Tree "%s": sent to %d recipient(s), %d failure(s).',
$tree->name(),