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', 'Upcoming events' => 'Anstehende Ereignisse', 'Birthdays of living kin and anniversaries of intact couples in the next %d days.' => 'Geburtstage lebender Verwandter und Hochzeitstage bestehender Paare in den nächsten %d Tagen.', 'Other upcoming events' => 'Weitere anstehende 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?', 'Subscribed users' => 'Abonnierte Nutzer', 'No users with email addresses found.' => 'Keine Nutzer mit E-Mail-Adresse gefunden.', 'Tick a user to subscribe them to this tree’s newsletter. Users can still adjust their own subscription on their account page.' => 'Setzen Sie einen Haken, um den Nutzer für den Newsletter dieses Stammbaums zu abonnieren. Nutzer können ihr Abonnement weiterhin selbst auf ihrer Kontoseite anpassen.', 'Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.' => 'Wird der E-Mail-Betreffzeile vorangestellt. Ein leeres Feld greift auf das generische Präfix unten zurück.', 'Generic' => 'Allgemein', 'Intro paragraph for the next email' => 'Einleitungsabsatz für die nächste E-Mail', 'Shown once, above the upcoming events. Cleared automatically after the next successful send.' => 'Wird einmalig über den anstehenden Ereignissen angezeigt. Wird nach dem nächsten erfolgreichen Versand automatisch geleert.', 'Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.' => 'Wird jedem Abonnenten einmalig in seinem eigenen Versandrhythmus zugestellt. Text bearbeiten und speichern, um eine neue Einleitung an alle zu versenden.', 'Personalisation tokens:' => 'Personalisierungs-Platzhalter:', 'Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.' => 'Formatiert als Markdown — z. B. %1$s für Hervorhebung, %2$s für einen Link.', 'Delivered to all %d subscriber(s).' => 'An alle %d Abonnenten zugestellt.', 'Delivered to %1$d of %2$d subscriber(s).' => 'An %1$d von %2$d Abonnenten zugestellt.', 'Pending' => 'Ausstehend', 'External recipients (%d)' => 'Externe Empfänger (%d)', 'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.', 'Upcoming family events' => 'Anstehende Familienereignisse', 'No upcoming family events in the next %d days.' => 'Keine anstehenden Familienereignisse in den nächsten %d Tagen.', 'Living kin celebrating in the next %d days.' => 'Lebende Verwandte, die in den nächsten %d Tagen feiern.', ], '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] ?? []; } // ─── Admin config page ────────────────────────────────────────── public function getAdminAction(): ResponseInterface { $this->layout = 'layouts/administration'; // Surface every webtrees user with an email so the admin can // toggle subscription per tree without having to ask each // member to opt in themselves. Sorted alphabetically by real // name so the list stays scannable in long member rosters. $users = $this->user_service->all() ->filter(static fn (User $user): bool => trim($user->email()) !== '') ->sortBy(static fn (User $user): string => mb_strtolower($user->realName())) ->values(); return $this->viewResponse($this->name() . '::admin', [ 'title' => I18N::translate('Email Newsletter') . ' — ' . I18N::translate('Preferences'), 'module' => $this, 'all_trees' => $this->tree_service->all(), 'all_users' => $users, 'cron_token' => $this->cronToken(), 'cron_url' => $this->cronUrl(), ]); } public function postAdminAction(ServerRequestInterface $request): ResponseInterface { // Cache the user list so we don't query it once per tree. $users = $this->user_service->all() ->filter(static fn (User $user): bool => trim($user->email()) !== ''); 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); // Generic prefix — used when no per-locale override is set. // We always write it (even empty) so admins can clear a // previously-saved value. $tree->setPreference(Configuration::PREF_SUBJECT_PREFIX, $subject); foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) { $locale_prefix = Validator::parsedBody($request) ->string('subject-' . $id . '-' . $code, ''); Configuration::setSubjectPrefixForLocale($tree, $code, $locale_prefix); $intro = Validator::parsedBody($request) ->string('intro-' . $id . '-' . $code, ''); // Bump the version only when the saved text actually // changed AND is non-empty. That makes "save the same // intro again" a no-op (no resends), while saving a // new non-empty paragraph re-delivers it to every // subscriber on their own cadence. $previous = Configuration::introForLocale($this, $tree, $code); Configuration::setIntroForLocale($this, $tree, $code, $intro); if ($intro !== '' && $intro !== $previous) { Configuration::bumpIntroVersion($this, $tree, $code); } } // Per-user subscription toggles. A users-roster marker is // always submitted (hidden field "users-submitted-") // so we can tell an unchecked-everyone POST apart from a // legacy form that omits the section entirely — we only // touch subscriptions when the marker is present. $roster_present = Validator::parsedBody($request) ->string('users-submitted-' . $id, '0') === '1'; if ($roster_present) { foreach ($users as $user) { $field = 'subscribe-' . $id . '-' . $user->id(); $subscribed = Validator::parsedBody($request)->string($field, '0') === '1'; $tree->setUserPreference( $user, Configuration::USER_PREF_SUBSCRIBED, $subscribed ? '1' : '0', ); } } } 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()); } // ─── Tree-home block ──────────────────────────────────────────── /** * Render an "Upcoming family events" block for the tree home page. * Reuses the same visualisation as the newsletter email (cards, * circular avatars, timeline rail, event icons) but adapted for * web context: avatars resolve to media-file URLs instead of CID * attachments, and relationship labels are computed against the * viewer's tree-linked Individual when available. * * Default look-ahead window is 30 days; admins can override per * block placement via the standard "configure" UI. * * @param array $config */ public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string { $window = (int) ($config['window_days'] ?? self::BLOCK_DEFAULT_WINDOW_DAYS); $window = max(1, min(365, $window)); // Cache the rendered block — relationship labels and avatar // URL lookups are per-viewer, so the cache key includes the // signed-in user id (0 for guests). 5-minute TTL is short // enough that admin edits propagate within one refresh. $viewer_id = Auth::user() instanceof User ? Auth::user()->id() : 0; $cache_key = sprintf( 'email_newsletter_block_%d_%d_%d_%s', $tree->id(), $window, $viewer_id, I18N::languageTag(), ); $content = Registry::cache()->file()->remember( $cache_key, fn (): string => $this->renderBlockContent($tree, $window), 300, ); if ($context !== self::CONTEXT_EMBED) { return view('modules/block-template', [ 'block' => Str::kebab($this->name()), 'id' => $block_id, 'config_url' => '', 'title' => I18N::translate('Upcoming family events'), 'content' => $content, ]); } return $content; } /** * Gather upcoming-event data and render the inner block HTML. Kept * separate from getBlock() so the result can be wrapped in a * file-cache without re-querying the database on every page load. */ private function renderBlockContent(Tree $tree, int $window): string { $birthdays = $this->event_query_service->upcomingBirthdays($tree, $window); $anniversaries = Configuration::includeAnniversaries($tree) ? $this->event_query_service->upcomingAnniversaries($tree, $window) : null; $historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window); // Featured individuals — every Individual referenced by any // fact in the block. Used to scope relationship labels and // avatar URLs. $featured = []; foreach ([$birthdays, $anniversaries, $historical] as $facts) { if ($facts === null) { continue; } foreach ($facts as $fact) { $record = $fact->record(); if ($record instanceof Individual) { $featured[$record->xref()] = $record; } elseif ($record instanceof \Fisharebest\Webtrees\Family) { foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $featured[$spouse->xref()] = $spouse; } } } } } $relationships = $this->viewerRelationships($tree, $featured); $avatar_srcs = $this->collectBlockAvatarSrcs($featured); return view($this->name() . '::block', [ 'tree' => $tree, 'birthdays' => $birthdays, 'anniversaries' => $anniversaries, 'historical' => $historical, 'include_anniversaries' => Configuration::includeAnniversaries($tree), 'window_days' => $window, 'avatar_srcs' => $avatar_srcs, 'relationships' => $relationships, ]); } public function loadAjax(): bool { // Defer the block to an async fetch so the rest of the tree // home page paints before our (cached) HTML arrives. Same // pattern webtrees uses for heavy stats blocks. return true; } public function isUserBlock(): bool { return false; } public function isTreeBlock(): bool { return true; } /** * Map xref => avatar src URL. Only entries for individuals with a * resolvable highlighted media file are present — the view treats * absence as "render an initials disc". * * @param array $featured * * @return array */ private function collectBlockAvatarSrcs(array $featured): array { $srcs = []; foreach ($featured as $xref => $individual) { try { $media_file = $individual->findHighlightedMediaFile(); } catch (\Throwable $ex) { continue; } if ($media_file === null || !$media_file->isImage()) { continue; } // 192 px source so the 56-px-rendered avatar stays crisp // on retina displays — matches the email-side resize. try { $srcs[$xref] = $media_file->imageUrl(192, 192, 'crop'); } catch (\Throwable $ex) { // imageUrl can throw on broken file paths; just skip. } } return $srcs; } /** * Build xref => "your mother" labels for the current viewer if * they're signed in and linked to an Individual on this tree. * * @param array $featured * * @return array */ private function viewerRelationships(Tree $tree, array $featured): array { $viewer = Auth::user(); if (!$viewer instanceof User) { return []; } $self_xref = $tree->getUserPreference($viewer, UserInterface::PREF_TREE_ACCOUNT_XREF); if ($self_xref === '') { return []; } $self = Registry::individualFactory()->make($self_xref, $tree); if (!$self instanceof Individual) { return []; } $map = []; foreach ($featured as $xref => $individual) { $label = $this->relationship_finder->label($self, $individual); if ($label !== null && $label !== '') { $map[$xref] = $label; } } return $map; } // ─── 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(), ]); } }