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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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">✓</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">⏳</span>
|
||||||
|
<?= e($user->realName()) ?>
|
||||||
|
<span class="text-muted"><<?= e($user->email()) ?>></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<?php if ($externals_pending) : ?>
|
||||||
|
<li>
|
||||||
|
<span class="text-warning">⏳</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>
|
||||||
|
|||||||
+71
-5
@@ -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).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
// last-seen version, so subscribers on slower
|
||||||
|
// cadences still get the message exactly once.
|
||||||
$intro = Configuration::introForLocale($module, $tree, $lang);
|
$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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user