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(), ]); } }