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
+115 -46
View File
@@ -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