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