Initial commit: webtrees Email Newsletter module
Recurring email newsletter for webtrees 2.2+. Each enabled tree
sends upcoming birthdays of living individuals, optional marriage
anniversaries of intact couples, and a once-per-calendar-month
historical section of births and deaths of deceased individuals.
Triggered exclusively by an external scheduler (system cron,
systemd timer, etc.) hitting a token-gated HTTP endpoint — never
on visitor page loads. The "is it due?" decision is idempotent
within the configured frequency window.
Per-user subscription is integrated into the built-in
/my-account/{tree} page via a custom view + a decorated
AccountUpdate handler. Admins can add external addresses and
trigger an immediate send for testing. Email body renders in
German for German-language users; English otherwise. Birthdays
and anniversaries are formatted with the upcoming-event ordinal
age (e.g. "45th birthday" / "45. Geburtstag").
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this
|
||||||
|
license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
The full text of the GNU Affero General Public License v3.0 is
|
||||||
|
available at <https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# webtrees Email Newsletter
|
||||||
|
|
||||||
|
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
|
||||||
|
automatically.
|
||||||
|
- **Once-per-month historical section**: 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.
|
||||||
|
|
||||||
|
## 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.**
|
||||||
|
|
||||||
|
## 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_`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp -r webtrees_email_newsletter /var/www/webtrees/modules_v4/email_newsletter
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### System cron
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# Run every 15 minutes. The module itself decides whether sending is due.
|
||||||
|
*/15 * * * * curl -fsS --max-time 60 'https://example.com/module/_email_newsletter_/Cron?token=YOUR_TOKEN' > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd timer
|
||||||
|
|
||||||
|
`/etc/systemd/system/webtrees-newsletter.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=webtrees newsletter trigger
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/curl -fsS --max-time 60 "https://example.com/module/_email_newsletter_/Cron?token=YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/webtrees-newsletter.timer`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Trigger webtrees newsletter dispatch
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:00/15
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Subscribers
|
||||||
|
|
||||||
|
Two sources, combined:
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0-or-later. See `LICENSE` for the full text.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "alex/webtrees-email-newsletter",
|
||||||
|
"description": "Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events from the family tree.",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"type": "webtrees-module",
|
||||||
|
"keywords": [
|
||||||
|
"webtrees",
|
||||||
|
"module",
|
||||||
|
"newsletter",
|
||||||
|
"email",
|
||||||
|
"birthday",
|
||||||
|
"anniversary"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alex",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-json": "*",
|
||||||
|
"fisharebest/webtrees": "~2.2.0 || dev-main"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"EmailNewsletter\\": "src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Newsletter module for webtrees.
|
||||||
|
*
|
||||||
|
* Sends recurring email newsletters with upcoming birthdays, marriage
|
||||||
|
* anniversaries, and historical events from the family tree.
|
||||||
|
*
|
||||||
|
* @license AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter;
|
||||||
|
|
||||||
|
use Composer\Autoload\ClassLoader;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
|
|
||||||
|
$loader = new ClassLoader();
|
||||||
|
$loader->addPsr4('EmailNewsletter\\', __DIR__ . '/src');
|
||||||
|
$loader->register();
|
||||||
|
|
||||||
|
return Registry::container()->get(Module::class);
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use EmailNewsletter\Configuration;
|
||||||
|
use EmailNewsletter\Module;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Module $module
|
||||||
|
* @var Collection<int,Tree> $all_trees
|
||||||
|
* @var string $cron_token
|
||||||
|
* @var string $cron_url
|
||||||
|
* @var string $title
|
||||||
|
*/
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?= view('components/breadcrumbs', [
|
||||||
|
'links' => [
|
||||||
|
route(ControlPanel::class) => I18N::translate('Control panel'),
|
||||||
|
route(ModulesAllPage::class) => I18N::translate('Modules'),
|
||||||
|
$title,
|
||||||
|
],
|
||||||
|
]) ?>
|
||||||
|
|
||||||
|
<h1><?= e($title) ?></h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<?= I18N::translate('Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).') ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
|
<?php foreach ($all_trees as $tree) : ?>
|
||||||
|
<?php
|
||||||
|
$id = $tree->id();
|
||||||
|
$enabled = Configuration::isEnabled($tree);
|
||||||
|
$frequency = Configuration::frequencyDays($tree);
|
||||||
|
$lookahead = Configuration::lookaheadDays($tree);
|
||||||
|
$histLook = Configuration::historicalLookaheadDays($tree);
|
||||||
|
$annivs = Configuration::includeAnniversaries($tree);
|
||||||
|
$subject = Configuration::subjectPrefix($tree);
|
||||||
|
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
|
||||||
|
$last_sent = Configuration::lastSentAt($tree);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<fieldset class="card mb-4">
|
||||||
|
<legend class="card-header h4"><?= e($tree->title()) ?></legend>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-sm-3 col-sm-9">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="enabled-<?= $id ?>" name="enabled-<?= $id ?>"
|
||||||
|
value="1" <?= $enabled ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="enabled-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Enable newsletter for this tree') ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="frequency-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Send newsletters every') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="input-group" style="max-width: 18rem;">
|
||||||
|
<input class="form-control" type="number"
|
||||||
|
id="frequency-<?= $id ?>" name="frequency-<?= $id ?>"
|
||||||
|
value="<?= e((string) $frequency) ?>"
|
||||||
|
min="<?= Configuration::MIN_FREQUENCY_DAYS ?>"
|
||||||
|
max="<?= Configuration::MAX_FREQUENCY_DAYS ?>" required>
|
||||||
|
<span class="input-group-text"><?= I18N::translate('days') ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="lookahead-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Look ahead') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="input-group" style="max-width: 18rem;">
|
||||||
|
<input class="form-control" type="number"
|
||||||
|
id="lookahead-<?= $id ?>" name="lookahead-<?= $id ?>"
|
||||||
|
value="<?= e((string) $lookahead) ?>"
|
||||||
|
min="<?= Configuration::MIN_LOOKAHEAD_DAYS ?>"
|
||||||
|
max="<?= Configuration::MAX_LOOKAHEAD_DAYS ?>" required>
|
||||||
|
<span class="input-group-text"><?= I18N::translate('days') ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-sm-3 col-sm-9">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="anniversaries-<?= $id ?>" name="anniversaries-<?= $id ?>"
|
||||||
|
value="1" <?= $annivs ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="anniversaries-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Include marriage anniversaries') ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
<?= I18N::translate('Only intact marriages of still-living couples are included.') ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="historical-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Historical look-ahead (days)') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input class="form-control" type="number" style="max-width: 18rem;"
|
||||||
|
id="historical-<?= $id ?>" name="historical-<?= $id ?>"
|
||||||
|
value="<?= e((string) $histLook) ?>" min="7" max="60" required>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
<?= I18N::translate('Births and deaths of deceased people are included once per calendar month.') ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Subject prefix') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<input class="form-control" type="text"
|
||||||
|
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
|
||||||
|
value="<?= e($subject) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
|
||||||
|
<?= I18N::translate('Extra recipient email addresses (one per line)') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<textarea class="form-control" rows="4"
|
||||||
|
id="extras-<?= $id ?>" name="extras-<?= $id ?>"><?= e($extras) ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($last_sent > 0) : ?>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-sm-3 col-sm-9">
|
||||||
|
<small class="text-muted">
|
||||||
|
<?= I18N::translate('Last sent: %s', date('Y-m-d H:i', $last_sent)) ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<?php endforeach ?>
|
||||||
|
|
||||||
|
<fieldset class="card mb-4">
|
||||||
|
<legend class="card-header h4"><?= I18N::translate('Cron token') ?></legend>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
<?= I18N::translate('Configure your system cron, systemd timer, or any external scheduler to call the URL below. The schedule decides when newsletters are actually due — calling more frequently is safe.') ?>
|
||||||
|
</p>
|
||||||
|
<pre class="bg-light p-2"><?= e($cron_url) ?></pre>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="regenerate_token"
|
||||||
|
name="regenerate_token" value="1">
|
||||||
|
<label class="form-check-label" for="regenerate_token">
|
||||||
|
<?= I18N::translate('Regenerate token') ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<?= view('icons/save') ?>
|
||||||
|
<?= I18N::translate('Save') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="<?= e(route('module', ['module' => $module->name(), 'action' => 'SendNowAdmin'])) ?>" class="mt-3">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button type="submit" class="btn btn-secondary"
|
||||||
|
onclick="return confirm('<?= e(I18N::translate('Send the newsletter now for every enabled tree?')) ?>');">
|
||||||
|
<?= I18N::translate('Send now') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customised version of webtrees' built-in edit-account-page view.
|
||||||
|
*
|
||||||
|
* Adds a "Newsletter subscription" fieldset that lets each user opt
|
||||||
|
* in or out per tree. The accompanying form field is processed by
|
||||||
|
* EmailNewsletter\Http\AccountUpdateDecorator after the built-in
|
||||||
|
* AccountUpdate handler has run.
|
||||||
|
*
|
||||||
|
* Keep the rest of this file in sync with the upstream view from
|
||||||
|
* webtrees 2.2 — resources/views/edit-account-page.phtml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use EmailNewsletter\Configuration;
|
||||||
|
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\AccountDelete;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string,string> $contact_methods
|
||||||
|
* @var Individual|null $default_individual
|
||||||
|
* @var array<string,string> $languages
|
||||||
|
* @var Individual|null $my_individual_record
|
||||||
|
* @var bool $show_delete_option
|
||||||
|
* @var array<string,string> $timezones
|
||||||
|
* @var string $title
|
||||||
|
* @var Tree|null $tree
|
||||||
|
* @var UserInterface $user
|
||||||
|
*/
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h2 class="wt-page-title">
|
||||||
|
<?= $title ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form method="post" class="wt-page-options wt-page-options-my-account">
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="user-name">
|
||||||
|
<?= I18N::translate('Username') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<input type="text" class="form-control" id="user-name" name="user_name" value="<?= e($user->userName()) ?>" dir="auto" aria-describedby="username-description" required="required">
|
||||||
|
<div class="form-text" id="username-description">
|
||||||
|
<?= I18N::translate('Usernames are case-insensitive and ignore accented letters, so that “chloe”, “chloë”, and “Chloe” are considered to be the same.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="real-name">
|
||||||
|
<?= I18N::translate('Real name') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<input type="text" class="form-control" id="real-name" name="real_name" value="<?= e($user->realName()) ?>" dir="auto" aria-describedby="real-name-description" required="required">
|
||||||
|
<div class="form-text" id="real-name-description">
|
||||||
|
<?= I18N::translate('This is your real name, as you would like it displayed on screen.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($tree instanceof Tree) : ?>
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="gedcom-id">
|
||||||
|
<?= I18N::translate('Individual record') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<select class="form-select" id="gedcom-id" aria-describedby="gedcom-id-description" disabled>
|
||||||
|
<?php if ($my_individual_record !== null) : ?>
|
||||||
|
<option value=""><?= $my_individual_record->fullName() ?></option>
|
||||||
|
<?php else : ?>
|
||||||
|
<option value=""><?= I18N::translateContext('unknown people', 'Unknown') ?></option>
|
||||||
|
<?php endif ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-text" id="gedcom-id-description">
|
||||||
|
<?= I18N::translate('This is a link to your own record in the family tree. If this is the wrong individual, contact an administrator.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="default-xref">
|
||||||
|
<?= I18N::translate('Default individual') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/select-individual', ['name' => 'default-xref', 'id' => 'default-xref', 'individual' => $default_individual, 'tree' => $tree]) ?>
|
||||||
|
<div class="form-text" id="default-xref-description">
|
||||||
|
<?= I18N::translate('This individual will be selected by default when viewing charts and reports.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="password">
|
||||||
|
<?= I18N::translate('Password') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<input class="form-control" type="password" id="password" name="password" aria-describedby="password-description" autocomplete="new-password" data-wt-show-password-text="<?= e(I18N::translate('show')) ?>" data-wt-show-password-title="<?= e(I18N::translate('Show password')) ?>" data-wt-hide-password-text="<?= e(I18N::translate('hide')) ?>" data-wt-hide-password-title="<?= e(I18N::translate('Hide password')) ?>">
|
||||||
|
<div class="form-text" id="password-description">
|
||||||
|
<?= I18N::translate('Passwords must be at least 8 characters long and are case-sensitive, so that “secret” is different from “SECRET”.') ?>
|
||||||
|
<br>
|
||||||
|
<?= I18N::translate('Leave the password blank if you want to keep the current password.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="language">
|
||||||
|
<?= I18N::translate('Language') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/select', ['name' => 'language', 'selected' => $user->getPreference(UserInterface::PREF_LANGUAGE, 'en-US'), 'options' => $languages]) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="timezone">
|
||||||
|
<?= I18N::translate('Time zone') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/select', ['name' => 'timezone', 'selected' => $user->getPreference(UserInterface::PREF_TIME_ZONE, 'UTC'), 'options' => $timezones]) ?>
|
||||||
|
<div class="form-text" id="timezone-description">
|
||||||
|
<?= I18N::translate('The time zone is required for date calculations, such as knowing today’s date.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="email">
|
||||||
|
<?= I18N::translate('Email address') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<input class="form-control" type="email" id="email" name="email" value="<?= e($user->email()) ?>" aria-describedby="email-description">
|
||||||
|
<div class="form-text" id="email-description">
|
||||||
|
<?= I18N::translate('This email address will be used to send password reminders, website notifications, and messages from other family members who are registered on the website.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-sm-3 col-form-label wt-page-options-label" for="contact-method">
|
||||||
|
<?= I18N::translate('Contact method') ?>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/select', ['name' => 'contact-method', 'id' => 'contact-method', 'selected' => $user->getPreference(UserInterface::PREF_CONTACT_METHOD), 'options' => $contact_methods]) ?>
|
||||||
|
<div class="form-text" id="contact-method-description">
|
||||||
|
<?= I18N::translate('Site members can send each other messages. You can choose to how these messages are sent to you, or choose not receive them at all.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="row">
|
||||||
|
<legend class="col-sm-3 col-form-label wt-page-options-label">
|
||||||
|
<?= I18N::translate('Visible online') ?>
|
||||||
|
</legend>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/checkbox', ['label' => I18N::translate('Visible to other users when online'), 'name' => 'visible-online', 'checked' => (bool) $user->getPreference(UserInterface::PREF_IS_VISIBLE_ONLINE)]) ?>
|
||||||
|
<div class="form-text" id="visible-online-description">
|
||||||
|
<?= I18N::translate('You can choose whether to appear in the list of users who are currently signed-in.') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<?php if ($tree instanceof Tree && Configuration::isEnabled($tree)) : ?>
|
||||||
|
<fieldset class="row">
|
||||||
|
<legend class="col-sm-3 col-form-label wt-page-options-label">
|
||||||
|
<?= I18N::translate('Newsletter subscription') ?>
|
||||||
|
</legend>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<?= view('components/checkbox', [
|
||||||
|
'label' => I18N::translate('Subscribe to the newsletter'),
|
||||||
|
'name' => 'newsletter_subscribed',
|
||||||
|
'checked' => $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1',
|
||||||
|
]) ?>
|
||||||
|
<div class="form-text">
|
||||||
|
<?= I18N::translate('You will receive a periodic email with upcoming birthdays and other family events from %s.', e($tree->title())) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-sm-3 wt-page-options-label"></div>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<?= view('icons/save') ?>
|
||||||
|
<?= I18N::translate('save') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($show_delete_option) : ?>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-sm-3 wt-page-options-label"></div>
|
||||||
|
<div class="col-sm-9 wt-page-options-value">
|
||||||
|
<a href="#" class="btn btn-danger" data-wt-confirm="<?= I18N::translate('Are you sure you want to delete “%s”?', e($user->userName())) ?>" data-wt-post-url="<?= e(route(AccountDelete::class)) ?>">
|
||||||
|
<?= view('icons/delete') ?>
|
||||||
|
<?= I18N::translate('Delete your account') ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Date;
|
||||||
|
use Fisharebest\Webtrees\Fact;
|
||||||
|
use Fisharebest\Webtrees\Family;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Tree $tree
|
||||||
|
* @var Collection<int,Fact> $birthdays
|
||||||
|
* @var Collection<int,Fact>|null $anniversaries
|
||||||
|
* @var Collection<int,Fact>|null $historical
|
||||||
|
* @var bool $include_anniversaries
|
||||||
|
* @var bool $include_historical
|
||||||
|
* @var int $lookahead_days
|
||||||
|
* @var int $historical_lookahead
|
||||||
|
* @var int $generated_at
|
||||||
|
* @var string $account_url
|
||||||
|
*/
|
||||||
|
|
||||||
|
$record_label = static function (Fact $fact): string {
|
||||||
|
$record = $fact->record();
|
||||||
|
|
||||||
|
if ($record instanceof Individual) {
|
||||||
|
return strip_tags($record->fullName());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof Family) {
|
||||||
|
$husband = $record->husband();
|
||||||
|
$wife = $record->wife();
|
||||||
|
$names = array_filter([
|
||||||
|
$husband !== null ? strip_tags($husband->fullName()) : '',
|
||||||
|
$wife !== null ? strip_tags($wife->fullName()) : '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return implode(' & ', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record->xref();
|
||||||
|
};
|
||||||
|
|
||||||
|
$event_date = static function (Fact $fact): string {
|
||||||
|
$date = $fact->date();
|
||||||
|
|
||||||
|
if (!$date instanceof Date || !$date->isOK()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strip_tags($date->display());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Age the person/couple actually turns on the upcoming anniversary, not
|
||||||
|
* their current age. We use the fact's own year (which on an anniversary
|
||||||
|
* Fact is the year of the original event — birth or marriage) and the
|
||||||
|
* year of the upcoming Julian day stored on the Fact ($fact->jd) so the
|
||||||
|
* calculation handles people whose birthday falls before vs. after today
|
||||||
|
* uniformly.
|
||||||
|
*/
|
||||||
|
$upcoming_age = static function (Fact $fact): int {
|
||||||
|
static $gregorian = null;
|
||||||
|
$gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
|
||||||
|
|
||||||
|
$date = $fact->date();
|
||||||
|
|
||||||
|
if (!$date->isOK()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event_year = $date->gregorianYear();
|
||||||
|
$upcoming_jd = $fact->jd ?? 0;
|
||||||
|
|
||||||
|
if ($upcoming_jd > 0) {
|
||||||
|
[$upcoming_year] = $gregorian->jdToYmd($upcoming_jd);
|
||||||
|
} else {
|
||||||
|
$upcoming_year = (int) date('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, $upcoming_year - $event_year);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale-aware ordinal. English uses st/nd/rd/th suffixes; German (and
|
||||||
|
* most other European languages we currently support) just appends a
|
||||||
|
* period to the digits.
|
||||||
|
*/
|
||||||
|
$ordinal = static function (int $n): string {
|
||||||
|
if (str_starts_with(I18N::languageTag(), 'de')) {
|
||||||
|
return $n . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$abs = abs($n);
|
||||||
|
$mod100 = $abs % 100;
|
||||||
|
|
||||||
|
if ($mod100 >= 11 && $mod100 <= 13) {
|
||||||
|
return $n . 'th';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $n . match ($abs % 10) {
|
||||||
|
1 => 'st',
|
||||||
|
2 => 'nd',
|
||||||
|
3 => 'rd',
|
||||||
|
default => 'th',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$birthday_label = static function (int $age) use ($ordinal): string {
|
||||||
|
if ($age <= 0) {
|
||||||
|
return I18N::translate('Birthday');
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18N::translate('%s birthday', $ordinal($age));
|
||||||
|
};
|
||||||
|
|
||||||
|
$anniversary_label = static function (int $age) use ($ordinal): string {
|
||||||
|
if ($age <= 0) {
|
||||||
|
return I18N::translate('Wedding anniversary');
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18N::translate('%s wedding anniversary', $ordinal($age));
|
||||||
|
};
|
||||||
|
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="<?= e(I18N::languageTag()) ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title><?= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?></title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Helvetica, Arial, sans-serif; color: #222; max-width: 640px; margin: 0 auto;">
|
||||||
|
|
||||||
|
<h1 style="border-bottom: 2px solid #888; padding-bottom: 0.3rem;">
|
||||||
|
<?= e($tree->title()) ?>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="color: #555;">
|
||||||
|
<?= e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (!$birthdays->isEmpty()) : ?>
|
||||||
|
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($birthdays as $fact) : ?>
|
||||||
|
<?php $age = $upcoming_age($fact); ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($record_label($fact)) ?></strong>
|
||||||
|
— <?= e($birthday_label($age)) ?>
|
||||||
|
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
|
||||||
|
<h2 style="color: #336;"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($anniversaries as $fact) : ?>
|
||||||
|
<?php $age = $upcoming_age($fact); ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($record_label($fact)) ?></strong>
|
||||||
|
— <?= e($anniversary_label($age)) ?>
|
||||||
|
<span style="color: #666;">(<?= e($event_date($fact)) ?>)</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
|
||||||
|
<h2 style="color: #663;"><?= e(I18N::translate('On this month in history')) ?></h2>
|
||||||
|
<p style="color: #666;">
|
||||||
|
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<?php foreach ($historical as $fact) : ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= e($record_label($fact)) ?></strong>
|
||||||
|
— <?= e($fact->label()) ?>: <?= e($event_date($fact)) ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<hr style="margin-top: 2rem; border: 0; border-top: 1px solid #ccc;">
|
||||||
|
<p style="color: #888; font-size: 0.85rem;">
|
||||||
|
<?= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
|
||||||
|
<br>
|
||||||
|
<?= I18N::translate(
|
||||||
|
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
|
||||||
|
'<a href="' . e($account_url) . '" style="color: #336;">' . e(I18N::translate('My account')) . '</a>',
|
||||||
|
) ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tree configuration for the Email Newsletter module.
|
||||||
|
*
|
||||||
|
* All values are persisted via $tree->setPreference()/getPreference().
|
||||||
|
* State values (last-sent timestamp, last historical-section month) are
|
||||||
|
* also stored on the tree because each tree produces its own newsletter.
|
||||||
|
*/
|
||||||
|
final class Configuration
|
||||||
|
{
|
||||||
|
// webtrees stores all *_setting tables with a varchar(32) setting_name
|
||||||
|
// column. Keys here MUST stay <= 32 characters.
|
||||||
|
public const string PREF_ENABLED = 'NEWSLETTER_ENABLED';
|
||||||
|
public const string PREF_FREQUENCY_DAYS = 'NEWSLETTER_FREQ_DAYS';
|
||||||
|
public const string PREF_LOOKAHEAD_DAYS = 'NEWSLETTER_LOOK_DAYS';
|
||||||
|
public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS';
|
||||||
|
public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS';
|
||||||
|
public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS';
|
||||||
|
public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT';
|
||||||
|
public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO';
|
||||||
|
public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX';
|
||||||
|
|
||||||
|
// Module-level (not tree-bound) settings.
|
||||||
|
public const string MODULE_PREF_CRON_TOKEN = 'cron_token';
|
||||||
|
|
||||||
|
// Per-user subscription preference. Set via $user->setPreference().
|
||||||
|
public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed';
|
||||||
|
|
||||||
|
public const int DEFAULT_FREQUENCY_DAYS = 14;
|
||||||
|
public const int DEFAULT_LOOKAHEAD_DAYS = 14;
|
||||||
|
public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30;
|
||||||
|
public const int MIN_FREQUENCY_DAYS = 1;
|
||||||
|
public const int MAX_FREQUENCY_DAYS = 90;
|
||||||
|
public const int MIN_LOOKAHEAD_DAYS = 1;
|
||||||
|
public const int MAX_LOOKAHEAD_DAYS = 60;
|
||||||
|
|
||||||
|
public static function isEnabled(Tree $tree): bool
|
||||||
|
{
|
||||||
|
return $tree->getPreference(self::PREF_ENABLED) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function frequencyDays(Tree $tree): int
|
||||||
|
{
|
||||||
|
$value = (int) $tree->getPreference(self::PREF_FREQUENCY_DAYS, (string) self::DEFAULT_FREQUENCY_DAYS);
|
||||||
|
|
||||||
|
return max(self::MIN_FREQUENCY_DAYS, min(self::MAX_FREQUENCY_DAYS, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lookaheadDays(Tree $tree): int
|
||||||
|
{
|
||||||
|
$value = (int) $tree->getPreference(self::PREF_LOOKAHEAD_DAYS, (string) self::DEFAULT_LOOKAHEAD_DAYS);
|
||||||
|
|
||||||
|
return max(self::MIN_LOOKAHEAD_DAYS, min(self::MAX_LOOKAHEAD_DAYS, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function includeAnniversaries(Tree $tree): bool
|
||||||
|
{
|
||||||
|
return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function historicalLookaheadDays(Tree $tree): int
|
||||||
|
{
|
||||||
|
$value = (int) $tree->getPreference(
|
||||||
|
self::PREF_HISTORICAL_LOOKAHEAD,
|
||||||
|
(string) self::DEFAULT_HISTORICAL_LOOKAHEAD,
|
||||||
|
);
|
||||||
|
|
||||||
|
return max(7, min(60, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function subjectPrefix(Tree $tree): string
|
||||||
|
{
|
||||||
|
return $tree->getPreference(self::PREF_SUBJECT_PREFIX, '[' . $tree->title() . '] ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra recipient email addresses configured by the admin (one per line).
|
||||||
|
*
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function extraRecipients(Tree $tree): array
|
||||||
|
{
|
||||||
|
$raw = $tree->getPreference(self::PREF_EXTRA_RECIPIENTS, '');
|
||||||
|
$lines = preg_split('/\R/', $raw) ?: [];
|
||||||
|
$lines = array_map('trim', $lines);
|
||||||
|
|
||||||
|
return array_values(array_filter($lines, static fn (string $line): bool => $line !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastSentAt(Tree $tree): int
|
||||||
|
{
|
||||||
|
return (int) $tree->getPreference(self::PREF_LAST_SENT_AT, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setLastSentAt(Tree $tree, int $timestamp): void
|
||||||
|
{
|
||||||
|
$tree->setPreference(self::PREF_LAST_SENT_AT, (string) $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastHistoricalMonth(Tree $tree): string
|
||||||
|
{
|
||||||
|
return $tree->getPreference(self::PREF_LAST_HISTORICAL_MONTH, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setLastHistoricalMonth(Tree $tree, string $yearMonth): void
|
||||||
|
{
|
||||||
|
$tree->setPreference(self::PREF_LAST_HISTORICAL_MONTH, $yearMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter\Http;
|
||||||
|
|
||||||
|
use EmailNewsletter\Configuration;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Fisharebest\Webtrees\User;
|
||||||
|
use Fisharebest\Webtrees\Validator;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the built-in AccountUpdate handler and adds persistence of the
|
||||||
|
* "Subscribe to the newsletter" checkbox we inject into the
|
||||||
|
* edit-account-page view.
|
||||||
|
*
|
||||||
|
* The webtrees AccountUpdate class is final, so we decorate it rather
|
||||||
|
* than subclassing it. The wrapped handler runs first (so the user's
|
||||||
|
* own changes go through the normal validation path); afterwards we
|
||||||
|
* persist the per-tree subscription flag.
|
||||||
|
*
|
||||||
|
* This handler is bound in place of AccountUpdate via the webtrees DI
|
||||||
|
* container — see EmailNewsletter\Module::boot().
|
||||||
|
*/
|
||||||
|
final class AccountUpdateDecorator implements RequestHandlerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccountUpdate $inner,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$response = $this->inner->handle($request);
|
||||||
|
|
||||||
|
$tree = Validator::attributes($request)->treeOptional();
|
||||||
|
$user = Validator::attributes($request)->user();
|
||||||
|
|
||||||
|
if ($tree instanceof Tree && $user instanceof User) {
|
||||||
|
$subscribed = Validator::parsedBody($request)
|
||||||
|
->boolean('newsletter_subscribed', false);
|
||||||
|
|
||||||
|
$tree->setUserPreference(
|
||||||
|
$user,
|
||||||
|
Configuration::USER_PREF_SUBSCRIBED,
|
||||||
|
$subscribed ? '1' : '0',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
+327
@@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Newsletter module for webtrees.
|
||||||
|
*
|
||||||
|
* @license AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter;
|
||||||
|
|
||||||
|
use EmailNewsletter\Http\AccountUpdateDecorator;
|
||||||
|
use EmailNewsletter\Services\NewsletterDispatchService;
|
||||||
|
use Fisharebest\Webtrees\Auth;
|
||||||
|
use Fisharebest\Webtrees\FlashMessages;
|
||||||
|
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
|
||||||
|
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Menu;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
|
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
|
||||||
|
use Fisharebest\Webtrees\Services\TreeService;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Fisharebest\Webtrees\Validator;
|
||||||
|
use Fisharebest\Webtrees\View;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
|
||||||
|
{
|
||||||
|
use ModuleCustomTrait;
|
||||||
|
use ModuleConfigTrait;
|
||||||
|
use ModuleMenuTrait;
|
||||||
|
|
||||||
|
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly NewsletterDispatchService $dispatch_service,
|
||||||
|
private readonly TreeService $tree_service,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return I18N::translate('Email Newsletter');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return I18N::translate('Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleAuthorName(): string
|
||||||
|
{
|
||||||
|
return 'Alex';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleVersion(): string
|
||||||
|
{
|
||||||
|
return '1.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleSupportUrl(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resourcesFolder(): string
|
||||||
|
{
|
||||||
|
return __DIR__ . '/../resources/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
|
||||||
|
|
||||||
|
// Inject our newsletter-subscription field into the built-in
|
||||||
|
// /my-account page and persist it via a decorated handler.
|
||||||
|
View::registerCustomView('::edit-account-page', $this->name() . '::edit-account-page');
|
||||||
|
|
||||||
|
$container = Registry::container();
|
||||||
|
$container->set(
|
||||||
|
AccountUpdate::class,
|
||||||
|
new AccountUpdateDecorator($container->get(AccountUpdate::class)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customTranslations(string $language): array
|
||||||
|
{
|
||||||
|
$translations = [
|
||||||
|
'de' => [
|
||||||
|
'Email Newsletter' => 'E-Mail-Newsletter',
|
||||||
|
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
|
||||||
|
=> 'Versendet regelmäßige Newsletter mit anstehenden Geburtstagen, Hochzeitstagen und historischen Ereignissen.',
|
||||||
|
'Family newsletter — %s' => 'Familien-Newsletter — %s',
|
||||||
|
'Upcoming birthdays' => 'Anstehende Geburtstage',
|
||||||
|
'Upcoming marriage anniversaries' => 'Anstehende Hochzeitstage',
|
||||||
|
'On this month in history' => 'In diesem Monat in der Geschichte',
|
||||||
|
'Newsletter subscription' => 'Newsletter-Abonnement',
|
||||||
|
'Subscribe to the newsletter' => 'Newsletter abonnieren',
|
||||||
|
'Send newsletters every' => 'Newsletter senden alle',
|
||||||
|
'days' => 'Tage',
|
||||||
|
'Look ahead' => 'Vorschau',
|
||||||
|
'Include marriage anniversaries' => 'Hochzeitstage einbeziehen',
|
||||||
|
'Historical look-ahead (days)' => 'Historische Vorschau (Tage)',
|
||||||
|
'Extra recipient email addresses (one per line)' => 'Zusätzliche Empfänger-E-Mail-Adressen (eine pro Zeile)',
|
||||||
|
'Subject prefix' => 'Betreff-Präfix',
|
||||||
|
'Save' => 'Speichern',
|
||||||
|
'Send now' => 'Jetzt senden',
|
||||||
|
'Cron token' => 'Cron-Token',
|
||||||
|
'Regenerate token' => 'Token neu generieren',
|
||||||
|
'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.',
|
||||||
|
'%s birthday' => '%s Geburtstag',
|
||||||
|
'%s wedding anniversary' => '%s Hochzeitstag',
|
||||||
|
'Birthday' => 'Geburtstag',
|
||||||
|
'Wedding anniversary' => 'Hochzeitstag',
|
||||||
|
'Events in the next %d days.' => 'Ereignisse in den nächsten %d Tagen.',
|
||||||
|
'Events in the next %d days for people who have passed away.'
|
||||||
|
=> 'Ereignisse in den nächsten %d Tagen für bereits verstorbene Personen.',
|
||||||
|
'%d years old' => '%d Jahre alt',
|
||||||
|
'%d years' => '%d Jahre',
|
||||||
|
'You will receive a periodic email with upcoming birthdays and other family events from %s.'
|
||||||
|
=> 'Sie erhalten regelmäßig eine E-Mail mit anstehenden Geburtstagen und weiteren Familienereignissen aus %s.',
|
||||||
|
'You are receiving this email because you subscribed to the %s newsletter.'
|
||||||
|
=> 'Sie erhalten diese E-Mail, weil Sie den Newsletter „%s“ abonniert haben.',
|
||||||
|
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.'
|
||||||
|
=> 'Um Ihr Abonnement zu ändern oder zu kündigen, bearbeiten Sie den Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s.',
|
||||||
|
'Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).'
|
||||||
|
=> 'Newsletter-Versand pro Stammbaum konfigurieren. Absender ist die Kontaktperson des jeweiligen Baums (alternativ der Webmaster der Seite).',
|
||||||
|
'Enable newsletter for this tree' => 'Newsletter für diesen Baum aktivieren',
|
||||||
|
'Only intact marriages of still-living couples are included.'
|
||||||
|
=> 'Nur bestehende Ehen lebender Paare werden berücksichtigt.',
|
||||||
|
'Births and deaths of deceased people are included once per calendar month.'
|
||||||
|
=> 'Geburten und Todestage verstorbener Personen werden einmal pro Kalendermonat einbezogen.',
|
||||||
|
'Last sent: %s' => 'Zuletzt gesendet: %s',
|
||||||
|
'Configure your system cron, systemd timer, or any external scheduler to call the URL below. The schedule decides when newsletters are actually due — calling more frequently is safe.'
|
||||||
|
=> 'Richten Sie System-Cron, systemd-Timer oder einen externen Scheduler so ein, dass er die untenstehende URL aufruft. Der Versandplan entscheidet, wann tatsächlich gesendet wird — häufiger aufrufen ist unbedenklich.',
|
||||||
|
'Send the newsletter now for every enabled tree?'
|
||||||
|
=> 'Newsletter jetzt für jeden aktivierten Baum senden?',
|
||||||
|
],
|
||||||
|
'nl' => [
|
||||||
|
'Email Newsletter' => 'E-mailnieuwsbrief',
|
||||||
|
'Sends recurring email newsletters with upcoming birthdays, marriage anniversaries, and historical events.'
|
||||||
|
=> 'Verstuurt periodieke nieuwsbrieven met aankomende verjaardagen, trouwdagen en historische gebeurtenissen.',
|
||||||
|
'Family newsletter — %s' => 'Familienieuwsbrief — %s',
|
||||||
|
'Upcoming birthdays' => 'Aankomende verjaardagen',
|
||||||
|
'Upcoming marriage anniversaries' => 'Aankomende trouwdagen',
|
||||||
|
'On this month in history' => 'Deze maand in de geschiedenis',
|
||||||
|
'Newsletter subscription' => 'Nieuwsbriefabonnement',
|
||||||
|
'Subscribe to the newsletter' => 'Abonneren op de nieuwsbrief',
|
||||||
|
'Send newsletters every' => 'Nieuwsbrieven verzenden elke',
|
||||||
|
'days' => 'dagen',
|
||||||
|
'Look ahead' => 'Vooruitkijken',
|
||||||
|
'Include marriage anniversaries' => 'Trouwdagen meenemen',
|
||||||
|
'Historical look-ahead (days)' => 'Historische vooruitblik (dagen)',
|
||||||
|
'Extra recipient email addresses (one per line)' => 'Extra ontvanger-e-mailadressen (één per regel)',
|
||||||
|
'Subject prefix' => 'Onderwerpvoorvoegsel',
|
||||||
|
'Save' => 'Opslaan',
|
||||||
|
'Send now' => 'Nu verzenden',
|
||||||
|
'Cron token' => 'Cron-token',
|
||||||
|
'Regenerate token' => 'Token opnieuw genereren',
|
||||||
|
'Your subscription has been updated.' => 'Uw abonnement is bijgewerkt.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $translations[$language] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Menu ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function defaultMenuOrder(): int
|
||||||
|
{
|
||||||
|
return 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMenu(Tree $tree): Menu|null
|
||||||
|
{
|
||||||
|
if (!Auth::check()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Configuration::isEnabled($tree)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Menu(
|
||||||
|
I18N::translate('Newsletter subscription'),
|
||||||
|
route(AccountEdit::class, ['tree' => $tree->name()]),
|
||||||
|
'menu-newsletter-subscription',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin config page ──────────────────────────────────────────
|
||||||
|
|
||||||
|
public function getAdminAction(): ResponseInterface
|
||||||
|
{
|
||||||
|
$this->layout = 'layouts/administration';
|
||||||
|
|
||||||
|
return $this->viewResponse($this->name() . '::admin', [
|
||||||
|
'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'),
|
||||||
|
'module' => $this,
|
||||||
|
'all_trees' => $this->tree_service->all(),
|
||||||
|
'cron_token' => $this->cronToken(),
|
||||||
|
'cron_url' => $this->cronUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postAdminAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
foreach ($this->tree_service->all() as $tree) {
|
||||||
|
$id = $tree->id();
|
||||||
|
$enabled = Validator::parsedBody($request)->string('enabled-' . $id, '0') === '1';
|
||||||
|
$frequency = Validator::parsedBody($request)
|
||||||
|
->isBetween(Configuration::MIN_FREQUENCY_DAYS, Configuration::MAX_FREQUENCY_DAYS)
|
||||||
|
->integer('frequency-' . $id, Configuration::DEFAULT_FREQUENCY_DAYS);
|
||||||
|
$lookahead = Validator::parsedBody($request)
|
||||||
|
->isBetween(Configuration::MIN_LOOKAHEAD_DAYS, Configuration::MAX_LOOKAHEAD_DAYS)
|
||||||
|
->integer('lookahead-' . $id, Configuration::DEFAULT_LOOKAHEAD_DAYS);
|
||||||
|
$histLook = Validator::parsedBody($request)
|
||||||
|
->isBetween(7, 60)
|
||||||
|
->integer('historical-' . $id, Configuration::DEFAULT_HISTORICAL_LOOKAHEAD);
|
||||||
|
$annivs = Validator::parsedBody($request)->string('anniversaries-' . $id, '0') === '1';
|
||||||
|
$extras = Validator::parsedBody($request)->string('extras-' . $id, '');
|
||||||
|
$subject = Validator::parsedBody($request)->string('subject-' . $id, '');
|
||||||
|
|
||||||
|
$tree->setPreference(Configuration::PREF_ENABLED, $enabled ? '1' : '0');
|
||||||
|
$tree->setPreference(Configuration::PREF_FREQUENCY_DAYS, (string) $frequency);
|
||||||
|
$tree->setPreference(Configuration::PREF_LOOKAHEAD_DAYS, (string) $lookahead);
|
||||||
|
$tree->setPreference(Configuration::PREF_HISTORICAL_LOOKAHEAD, (string) $histLook);
|
||||||
|
$tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0');
|
||||||
|
$tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras);
|
||||||
|
|
||||||
|
if ($subject !== '') {
|
||||||
|
$tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Validator::parsedBody($request)->string('regenerate_token', '0') === '1') {
|
||||||
|
$this->setPreference(self::SETTING_CRON_TOKEN, $this->generateToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
FlashMessages::addMessage(
|
||||||
|
I18N::translate('The preferences for the module “%s” have been updated.', $this->title()),
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect($this->getConfigLink());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-triggered immediate run (bypasses the "due" check, hits all
|
||||||
|
* enabled trees). Useful for testing without waiting for the timer.
|
||||||
|
*/
|
||||||
|
public function postSendNowAdminAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$log = $this->dispatch_service->dispatch($this, true);
|
||||||
|
|
||||||
|
foreach ($log as $line) {
|
||||||
|
FlashMessages::addMessage($line, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect($this->getConfigLink());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered by an external timer (system cron, systemd timer, etc.)
|
||||||
|
* via a plain HTTP GET. Authentication is by shared-secret token —
|
||||||
|
* compared in constant time. No webtrees session is required.
|
||||||
|
*
|
||||||
|
* Never invoked by ordinary website traffic.
|
||||||
|
*/
|
||||||
|
public function getCronAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$supplied = Validator::queryParams($request)->string('token', '');
|
||||||
|
$expected = $this->cronToken();
|
||||||
|
|
||||||
|
if ($expected === '' || !hash_equals($expected, $supplied)) {
|
||||||
|
throw new HttpAccessDeniedException('Invalid cron token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = Validator::queryParams($request)->string('force', '0') === '1';
|
||||||
|
$log = $this->dispatch_service->dispatch($this, $force);
|
||||||
|
|
||||||
|
return response(
|
||||||
|
"Newsletter dispatch run\n" . implode("\n", $log) . "\n",
|
||||||
|
)->withHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function cronToken(): string
|
||||||
|
{
|
||||||
|
$token = $this->getPreference(self::SETTING_CRON_TOKEN);
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
$token = $this->generateToken();
|
||||||
|
$this->setPreference(self::SETTING_CRON_TOKEN, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cronUrl(): string
|
||||||
|
{
|
||||||
|
return route('module', [
|
||||||
|
'module' => $this->name(),
|
||||||
|
'action' => 'Cron',
|
||||||
|
'token' => $this->cronToken(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter\Services;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Family;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
|
use Fisharebest\Webtrees\Services\CalendarService;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the events that should appear in the newsletter.
|
||||||
|
*
|
||||||
|
* Wraps CalendarService and adds the project-specific filtering rules
|
||||||
|
* (living vs. deceased, "marriage still active", historical section).
|
||||||
|
*/
|
||||||
|
final class EventQueryService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CalendarService $calendar_service,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birthdays of still-living individuals in the upcoming window.
|
||||||
|
*
|
||||||
|
* Window is [tomorrow, tomorrow + days - 1] so we never re-announce
|
||||||
|
* a birthday on the very day of the next scheduled send.
|
||||||
|
*
|
||||||
|
* @return Collection<int,\Fisharebest\Webtrees\Fact>
|
||||||
|
*/
|
||||||
|
public function upcomingBirthdays(Tree $tree, int $days): Collection
|
||||||
|
{
|
||||||
|
[$start, $end] = $this->window($days);
|
||||||
|
|
||||||
|
return $this->calendar_service
|
||||||
|
->getEventsList($start, $end, 'BIRT', true, 'anniv', $tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marriage anniversaries of intact couples — both spouses still
|
||||||
|
* living, and no divorce/annulment fact recorded.
|
||||||
|
*
|
||||||
|
* @return Collection<int,\Fisharebest\Webtrees\Fact>
|
||||||
|
*/
|
||||||
|
public function upcomingAnniversaries(Tree $tree, int $days): Collection
|
||||||
|
{
|
||||||
|
[$start, $end] = $this->window($days);
|
||||||
|
|
||||||
|
$facts = $this->calendar_service
|
||||||
|
->getEventsList($start, $end, 'MARR', true, 'anniv', $tree);
|
||||||
|
|
||||||
|
// CalendarService already filtered out families where either
|
||||||
|
// spouse is dead. We additionally drop families that have a
|
||||||
|
// DIV, DIVF or ANUL fact recorded.
|
||||||
|
return $facts->filter(static function ($fact): bool {
|
||||||
|
$record = $fact->record();
|
||||||
|
|
||||||
|
if (!$record instanceof Family) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($record->facts(['DIV', 'DIVF', 'ANUL']) as $end_fact) {
|
||||||
|
if ($end_fact->canShow()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Births and deaths of deceased individuals in the upcoming window.
|
||||||
|
* Used for the once-per-month historical section.
|
||||||
|
*
|
||||||
|
* @return Collection<int,\Fisharebest\Webtrees\Fact>
|
||||||
|
*/
|
||||||
|
public function upcomingHistoricalEvents(Tree $tree, int $days): Collection
|
||||||
|
{
|
||||||
|
[$start, $end] = $this->window($days);
|
||||||
|
|
||||||
|
$facts = $this->calendar_service
|
||||||
|
->getEventsList($start, $end, 'BIRT|DEAT', false, 'anniv', $tree);
|
||||||
|
|
||||||
|
return $facts->filter(static function ($fact): bool {
|
||||||
|
$record = $fact->record();
|
||||||
|
|
||||||
|
return $record instanceof Individual && $record->isDead();
|
||||||
|
})->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int,1:int} Julian day numbers for the window.
|
||||||
|
*/
|
||||||
|
private function window(int $days): array
|
||||||
|
{
|
||||||
|
$now = Registry::timestampFactory()->now();
|
||||||
|
$start = $now->addDays(1)->julianDay();
|
||||||
|
$end = $now->addDays($days)->julianDay();
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter\Services;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal UserInterface implementation for admin-added recipients that
|
||||||
|
* aren't backed by a webtrees user account. EmailService::send accepts
|
||||||
|
* any UserInterface, so this lets us reuse the same pipeline for both
|
||||||
|
* subscribed users and external addresses.
|
||||||
|
*/
|
||||||
|
final class ExtraRecipient implements UserInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $email,
|
||||||
|
private readonly string $real_name = '',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): int
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function email(): string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function realName(): string
|
||||||
|
{
|
||||||
|
return $this->real_name !== '' ? $this->real_name : $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userName(): string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreference(string $setting_name, string $default = ''): string
|
||||||
|
{
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPreference(string $setting_name, string $setting_value): void
|
||||||
|
{
|
||||||
|
// External recipients have no preferences to persist.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace EmailNewsletter\Services;
|
||||||
|
|
||||||
|
use EmailNewsletter\Configuration;
|
||||||
|
use Fisharebest\Webtrees\Contracts\UserInterface;
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Log;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleInterface;
|
||||||
|
use Fisharebest\Webtrees\Services\EmailService;
|
||||||
|
use Fisharebest\Webtrees\Services\TreeService;
|
||||||
|
use Fisharebest\Webtrees\Services\UserService;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use Fisharebest\Webtrees\User;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Component\Mime\Exception\RfcComplianceException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which trees are due, builds per-tree newsletters, and dispatches
|
||||||
|
* them via the webtrees EmailService.
|
||||||
|
*
|
||||||
|
* The "due" decision is based solely on stored timestamps and the
|
||||||
|
* configured frequency. It is intentionally independent of who triggered
|
||||||
|
* the run, so this is safe to call from an external cron without
|
||||||
|
* worrying about double-sends from concurrent invocations (idempotent
|
||||||
|
* within the same window, modulo a very small race the lock guards).
|
||||||
|
*/
|
||||||
|
final class NewsletterDispatchService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Anonymous "from" address fallback when the site has no contact user.
|
||||||
|
*/
|
||||||
|
private const string FROM_NAME = 'webtrees newsletter';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EventQueryService $event_query_service,
|
||||||
|
private readonly EmailService $email_service,
|
||||||
|
private readonly TreeService $tree_service,
|
||||||
|
private readonly UserService $user_service,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the dispatch cycle for every tree.
|
||||||
|
*
|
||||||
|
* @return array<int,string> Human-readable log lines describing what
|
||||||
|
* happened, suitable for the cron endpoint
|
||||||
|
* to return to the caller.
|
||||||
|
*/
|
||||||
|
public function dispatch(ModuleInterface $module, bool $force = false): array
|
||||||
|
{
|
||||||
|
$log = [];
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
foreach ($this->tree_service->all() as $tree) {
|
||||||
|
if (!Configuration::isEnabled($tree)) {
|
||||||
|
$log[] = sprintf('Tree "%s": disabled, skipping.', $tree->name());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$due_at = Configuration::lastSentAt($tree)
|
||||||
|
+ Configuration::frequencyDays($tree) * 86400;
|
||||||
|
|
||||||
|
if (!$force && $now < $due_at) {
|
||||||
|
$log[] = sprintf(
|
||||||
|
'Tree "%s": not due yet (next send in %d hours).',
|
||||||
|
$tree->name(),
|
||||||
|
(int) (($due_at - $now) / 3600),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$log[] = $this->dispatchForTree($tree, $module, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now): string
|
||||||
|
{
|
||||||
|
$include_anniversaries = Configuration::includeAnniversaries($tree);
|
||||||
|
$lookahead = Configuration::lookaheadDays($tree);
|
||||||
|
$historical_lookahead = Configuration::historicalLookaheadDays($tree);
|
||||||
|
$current_month = date('Y-m', $now);
|
||||||
|
$include_historical = Configuration::lastHistoricalMonth($tree) !== $current_month;
|
||||||
|
|
||||||
|
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $lookahead);
|
||||||
|
$anniversaries = $include_anniversaries
|
||||||
|
? $this->event_query_service->upcomingAnniversaries($tree, $lookahead)
|
||||||
|
: null;
|
||||||
|
$historical = $include_historical
|
||||||
|
? $this->event_query_service->upcomingHistoricalEvents($tree, $historical_lookahead)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Suppress entirely empty newsletters so subscribers don't get
|
||||||
|
// a near-empty email on a slow fortnight.
|
||||||
|
$has_content = !$birthdays->isEmpty()
|
||||||
|
|| ($anniversaries !== null && !$anniversaries->isEmpty())
|
||||||
|
|| ($historical !== null && !$historical->isEmpty());
|
||||||
|
|
||||||
|
if (!$has_content) {
|
||||||
|
Configuration::setLastSentAt($tree, $now);
|
||||||
|
|
||||||
|
return sprintf('Tree "%s": nothing to report, send timestamp advanced.', $tree->name());
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipients = $this->collectRecipients($tree);
|
||||||
|
|
||||||
|
if ($recipients === []) {
|
||||||
|
return sprintf('Tree "%s": no subscribers, skipped.', $tree->name());
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->siteContact($tree);
|
||||||
|
$original_locale = I18N::languageTag();
|
||||||
|
$groups = $this->groupRecipientsByLanguage($recipients);
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
$failures = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($groups as $lang => $group) {
|
||||||
|
I18N::init($lang);
|
||||||
|
|
||||||
|
$subject = Configuration::subjectPrefix($tree) . I18N::translate(
|
||||||
|
'Family newsletter — %s',
|
||||||
|
date('F j, Y', $now),
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = view($module->name() . '::email', [
|
||||||
|
'tree' => $tree,
|
||||||
|
'birthdays' => $birthdays,
|
||||||
|
'anniversaries' => $anniversaries,
|
||||||
|
'historical' => $historical,
|
||||||
|
'include_anniversaries' => $include_anniversaries,
|
||||||
|
'include_historical' => $include_historical,
|
||||||
|
'lookahead_days' => $lookahead,
|
||||||
|
'historical_lookahead' => $historical_lookahead,
|
||||||
|
'generated_at' => $now,
|
||||||
|
'account_url' => route(
|
||||||
|
\Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class,
|
||||||
|
['tree' => $tree->name()],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$text = $this->htmlToText($html);
|
||||||
|
|
||||||
|
foreach ($group as $recipient) {
|
||||||
|
try {
|
||||||
|
if ($this->email_service->send($from, $recipient, $from, $subject, $text, $html)) {
|
||||||
|
$sent++;
|
||||||
|
} else {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
} catch (RfcComplianceException | TransportExceptionInterface $ex) {
|
||||||
|
$failures++;
|
||||||
|
Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Always restore the original locale, even if a render or
|
||||||
|
// send throws an unexpected exception.
|
||||||
|
I18N::init($original_locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration::setLastSentAt($tree, $now);
|
||||||
|
if ($include_historical) {
|
||||||
|
Configuration::setLastHistoricalMonth($tree, $current_month);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Tree "%s": sent to %d recipient(s), %d failure(s)%s.',
|
||||||
|
$tree->name(),
|
||||||
|
$sent,
|
||||||
|
$failures,
|
||||||
|
$include_historical ? ', monthly historical section included' : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group recipients by the language we will render their email in.
|
||||||
|
* German users get "de"; everyone else (including admin-added
|
||||||
|
* external addresses) gets "en-US" for now.
|
||||||
|
*
|
||||||
|
* @param array<int,UserInterface> $recipients
|
||||||
|
* @return array<string,array<int,UserInterface>>
|
||||||
|
*/
|
||||||
|
private function groupRecipientsByLanguage(array $recipients): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$pref = $recipient->getPreference(UserInterface::PREF_LANGUAGE, '');
|
||||||
|
$lang = str_starts_with(strtolower($pref), 'de') ? 'de' : 'en-US';
|
||||||
|
|
||||||
|
$groups[$lang][] = $recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,UserInterface>
|
||||||
|
*/
|
||||||
|
private function collectRecipients(Tree $tree): array
|
||||||
|
{
|
||||||
|
$recipients = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($this->user_service->all() as $user) {
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only deliver to approved + email-verified accounts.
|
||||||
|
if ($user->getPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->getPreference(UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) !== '1') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim($user->email());
|
||||||
|
|
||||||
|
if ($email === '' || isset($seen[strtolower($email)])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[strtolower($email)] = true;
|
||||||
|
$recipients[] = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Configuration::extraRecipients($tree) as $email) {
|
||||||
|
$key = strtolower($email);
|
||||||
|
|
||||||
|
if (isset($seen[$key]) || !$this->email_service->isValidEmail($email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
$recipients[] = new ExtraRecipient($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a sender identity. Fall back to a synthetic UserInterface if
|
||||||
|
* the site has no contact user configured for this tree.
|
||||||
|
*/
|
||||||
|
private function siteContact(Tree $tree): UserInterface
|
||||||
|
{
|
||||||
|
$contact_id = (int) $tree->getPreference('CONTACT_USER_ID');
|
||||||
|
|
||||||
|
if ($contact_id !== 0) {
|
||||||
|
$user = $this->user_service->find($contact_id);
|
||||||
|
|
||||||
|
if ($user !== null && trim($user->email()) !== '') {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$webmaster_id = (int) \Fisharebest\Webtrees\Site::getPreference('WEBMASTER_USER_ID');
|
||||||
|
|
||||||
|
if ($webmaster_id !== 0) {
|
||||||
|
$user = $this->user_service->find($webmaster_id);
|
||||||
|
|
||||||
|
if ($user !== null && trim($user->email()) !== '') {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: derive a sender address from the server hostname.
|
||||||
|
// The mail transport must accept this; admins will normally have
|
||||||
|
// a contact user set, so this only matters for fresh installs.
|
||||||
|
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
|
||||||
|
$address = 'no-reply@' . preg_replace('/[^a-zA-Z0-9.\-]/', '', $host);
|
||||||
|
|
||||||
|
return new ExtraRecipient($address, self::FROM_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function htmlToText(string $html): string
|
||||||
|
{
|
||||||
|
$without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html;
|
||||||
|
$without_tags = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $without_tags) ?? $without_tags;
|
||||||
|
$stripped = strip_tags($without_tags);
|
||||||
|
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
// Collapse runs of blank lines.
|
||||||
|
return trim(preg_replace("/\n{3,}/", "\n\n", $decoded) ?? $decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user