diff --git a/README.md b/README.md index 70b7a8c..37719ef 100644 --- a/README.md +++ b/README.md @@ -3,35 +3,73 @@ A [webtrees](https://www.webtrees.net/) 2.2+ custom module that sends recurring email newsletters with: -- **Upcoming birthdays** of still-living individuals. -- **Upcoming marriage anniversaries** of intact couples (optional — admin - toggle, per tree). Marriages with a divorce or annulment fact are excluded +- **Upcoming birthdays** of still-living individuals (formatted as ordinal + age — *"45th birthday"* / *"45. Geburtstag"*). +- **Upcoming marriage anniversaries** of intact couples (admin toggle, per + tree). Marriages with a divorce or annulment fact are excluded automatically. -- **Once-per-month historical section**: births and deaths of deceased - individuals whose anniversary falls in the upcoming window. +- **Historical events** — births and deaths of deceased individuals whose + anniversary falls in the upcoming window. -The decision to actually send is made by comparing a stored "last sent" -timestamp to the configured frequency, so the dispatch run is idempotent — -calling the trigger more often than the frequency simply does nothing -extra. +The look-ahead window and the send cadence are the same number: one +"every N days" setting (default 14) drives both the cron interval and how +far each issue looks ahead. Issues with nothing to report are silently +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 - webtrees ≥ 2.2.0 -- PHP ≥ 8.2 -- A working SMTP / sendmail configuration in *webtrees → Control panel → - Sending email* (this module reuses webtrees' standard mailer). -- An external scheduler on the host: system `cron`, a `systemd` timer, - a Kubernetes `CronJob`, or anything else that can fire an HTTP request - at a fixed interval. **Newsletter dispatch never runs on visitor page - loads — it only runs when the scheduler triggers it.** +- PHP ≥ 8.2 with `ext-intl` (for locale-aware subject dates) and + either `ext-imagick` or `ext-gd` (for avatar resizing — falls back + to original-size embeds if neither is present). +- A working SMTP / sendmail configuration in *Control panel → Sending + email*. This module reuses webtrees' standard mailer and signs with + the site's DKIM keys if configured. +- 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 1. Copy this directory into the webtrees `modules_v4/` folder, renaming - it to `email_newsletter` (the folder name determines the internal - module identifier — the registered name will be - `_email_newsletter_`). + it to `email_newsletter`: ```sh 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 enable **Email Newsletter**. -3. Open *Control panel → Modules → Email Newsletter → Preferences* and: - - Enable newsletter dispatch per tree. - - Pick a frequency (default: 14 days). - - Optionally toggle marriage anniversaries and add any extra - external email addresses. - - Copy the **Cron URL** at the bottom — this is the secret-token - URL your scheduler must hit. +3. Open *Control panel → Modules → Email Newsletter → Preferences* + and, for each tree: + + - Tick *Enable newsletter for this tree*. + - Set the send-cadence (default 14 days). This same number is the + look-ahead window for the next issue. + - 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 -> **Why no built-in scheduler?** PHP has no daemon, and frameworks like -> Laravel rely on a once-per-minute system cron to fire their internal -> scheduler. This module follows the same convention: the host OS owns -> the timer, the module owns the "is it actually due?" decision. +> **Why no built-in scheduler?** PHP has no daemon, and frameworks +> like Laravel rely on a once-per-minute system cron to fire their +> internal scheduler. This module follows the same convention: the +> host OS owns the timer, the module owns the "is it actually due?" +> decision. ### System cron @@ -94,29 +145,47 @@ Then `systemctl enable --now webtrees-newsletter.timer`. ### Forcing a one-off send The admin **Preferences** page has a *Send now* button for testing. -For an unattended one-off send, append `&force=1` to the cron URL — -that bypasses the "is it due?" check. +For an unattended forced send (bypassing the per-recipient "is it +due?" check), append `&force=1` to the cron URL. ## Subscribers -Two sources, combined: +Three sources, combined and de-duplicated by email: -1. **Logged-in webtrees users** who opt in via the per-tree - *Newsletter subscription* menu entry (visible only to logged-in - users on trees where the module is enabled). Only **approved and - email-verified** accounts will receive the newsletter. -2. **External addresses** the tree administrator lists in the - *Extra recipient email addresses* textarea (one per line). +1. **Webtrees users** who opt in themselves via the per-tree + *Newsletter subscription* menu entry on `/my-account/{tree}` — + visible only to logged-in users on trees where the module is + enabled. +2. **Webtrees users** an admin subscribes from the preferences page. +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 -The dispatch service does not impersonate a webtrees user, so it sees -the tree from the **visitor** access level. Records and facts 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 -safest default for an outbound email — if you need to expose more -information, relax the tree's visitor-access settings or hand-curate -the *Extra recipient* list. +The dispatch service does not impersonate a webtrees user, so it +sees the tree from the **visitor** access level. Records and facts +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 safest default for an outbound email — if you need to expose +more information, relax the tree's visitor-access settings or +hand-curate the *Extra recipient* list. ## License diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml index b92d64c..78e594a 100644 --- a/resources/views/admin.phtml +++ b/resources/views/admin.phtml @@ -173,7 +173,7 @@ use Illuminate\Support\Collection;
- +
**bold**', '[label](https://example.org)') ?>
@@ -183,17 +183,92 @@ use Illuminate\Support\Collection; {{username}}, {{email}}
+ 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); + ?> $label) : ?> 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); ?> -
+
+ + 0) : ?> +
+ + + + + + +
+ + + +
    + +
  • + + realName()) ?> + <email()) ?>> +
  • + + +
  • + + +
  • + +
+
+ + +
+ +
+ +
+
diff --git a/src/Configuration.php b/src/Configuration.php index 2677260..b77aea3 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -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). * diff --git a/src/Module.php b/src/Module.php index f465dd2..c0506d2 100644 --- a/src/Module.php +++ b/src/Module.php @@ -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 diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index d30ce57..7c62fd7 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -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(),