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:
+72
-6
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user