00478e2466
Admin-facing simplification:
- Dropped separate \"lookahead\" and \"historical lookahead\" tree
prefs (and the once-per-month historical gate). A single
\"send every N days\" number now drives both the cron cadence
and the window each issue looks ahead for living + deceased
events.
- Default 14, range 1–90, applies uniformly.
User-facing addition:
- The /my-account/{tree} subscription card gained an \"Email
frequency\" select with options: use site default, weekly,
every 2 weeks, monthly, every 2 months, quarterly. Stored as
a per-tree-per-user preference.
- Dispatch now checks each recipient's own cadence against
their own last-sent timestamp. Admin-added external addresses
with no webtrees account always receive every run (no
per-user state).
- Newsletter footer now reads \"You can change how often you
receive this email, or unsubscribe entirely, in the Newsletter
subscription section on your My account page\" — true now
that the control exists.
German translations updated for the new strings; stale ones
removed.
339 lines
19 KiB
PHP
339 lines
19 KiB
PHP
<?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',
|
||
'Each issue looks the same number of days ahead, for both living relatives and historical events of those who have passed away. Default 14.'
|
||
=> 'Jede Ausgabe blickt um die gleiche Anzahl Tage in die Zukunft, sowohl für lebende Verwandte als auch für historische Ereignisse bereits verstorbener Personen. Standardwert 14.',
|
||
'Include marriage anniversaries' => 'Hochzeitstage einbeziehen',
|
||
'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.',
|
||
'Family Chronicle' => 'Familienchronik',
|
||
'Living kin who will celebrate this fortnight.'
|
||
=> 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
|
||
'Marriages still intact.' => 'Noch bestehende Ehen.',
|
||
'Other birthdays' => 'Weitere Geburtstage',
|
||
'Other anniversaries' => 'Weitere Hochzeitstage',
|
||
'Other historical events' => 'Weitere historische Ereignisse',
|
||
'Detailed view distance' => 'Detailansicht-Abstand',
|
||
'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.'
|
||
=> 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
|
||
'%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.',
|
||
'You can change how often you receive this email, or unsubscribe entirely, in the “Newsletter subscription” section on your %s page.'
|
||
=> 'Wie oft Sie diese E-Mail erhalten – oder ob Sie sie ganz abbestellen möchten – können Sie im Abschnitt „Newsletter-Abonnement“ auf Ihrer Seite %s ändern.',
|
||
'Email frequency' => 'E-Mail-Häufigkeit',
|
||
'Use site default (every %d days)' => 'Standard der Seite verwenden (alle %d Tage)',
|
||
'Weekly' => 'Wöchentlich',
|
||
'Every 2 weeks' => 'Alle 2 Wochen',
|
||
'Monthly' => 'Monatlich',
|
||
'Every 2 months' => 'Alle 2 Monate',
|
||
'Quarterly' => 'Vierteljährlich',
|
||
'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.',
|
||
'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);
|
||
$lineal = Validator::parsedBody($request)
|
||
->isBetween(Configuration::MIN_LINEAL_DEPTH, Configuration::MAX_LINEAL_DEPTH)
|
||
->integer('lineal-' . $id, Configuration::DEFAULT_LINEAL_DEPTH);
|
||
$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_LINEAL_DEPTH, (string) $lineal);
|
||
$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(),
|
||
]);
|
||
}
|
||
|
||
}
|