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
+72 -6
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace EmailNewsletter;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
/**
* 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_FREQUENCY_DAYS = 'NEWSLETTER_USER_FREQ_DAYS';
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_LINEAL_DEPTH = 3;
@@ -217,14 +220,67 @@ final class Configuration
}
/**
* Wipe every locale's intro paragraph. Called by the dispatcher
* after a successful send so the next issue starts clean.
* Monotonic version counter for the per-locale intro paragraph.
* 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) {
$module->setPreference(self::introKey($tree, $code), '');
}
$code = self::canonicalSubjectLocale($language);
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
@@ -232,6 +288,16 @@ final class Configuration
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).
*
+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',
'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.',
'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:',
'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.',
'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' => [
'Email Newsletter' => 'E-mailnieuwsbrief',
@@ -293,7 +300,17 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
$intro = Validator::parsedBody($request)
->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);
if ($intro !== '' && $intro !== $previous) {
Configuration::bumpIntroVersion($this, $tree, $code);
}
}
// 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
// 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(),