4ceade9079
Reworks the newsletter as a family-chronicle layout: ivory paper
background, deep oxblood ink, aged-gold accents, EB Garamond
display with Georgia body fallback.
- Inline SVG event icons (sparkle for birth, dagger for death,
interlocked rings for marriage). Falls back silently in
Outlook desktop; modern Gmail / Apple / iOS / Outlook 365
render them.
- Right-side gold hairline timeline running through the date
column of every event row, with a filled dot per entry.
- Person names link to their webtrees Individual page via
Individual::url() (absolute URL through route() → BASE_URL),
including the avatar circles.
- German strings added for the new section kickers
("Family Chronicle", "Living kin who will celebrate this
fortnight.", "Marriages still intact.").
332 lines
16 KiB
PHP
332 lines
16 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',
|
|
'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.',
|
|
'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.',
|
|
'%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(),
|
|
]);
|
|
}
|
|
|
|
}
|