Human-readable log lines describing what * happened, suitable for the cron endpoint * to return to the caller. */ public function dispatch(AbstractModule $module, bool $force = false): array { $log = []; $now = time(); foreach ($this->tree_service->all() as $tree) { if (!Configuration::isEnabled($tree)) { $log[] = sprintf('Tree "%s": disabled, skipping.', $tree->name()); continue; } // Per-tree "is anyone due?" is decided inside dispatchForTree // — each recipient has their own cadence and last-sent // timestamp, so the gate is per-user, not per-tree. $log[] = $this->dispatchForTree($tree, $module, $now, $force); } return $log; } private function dispatchForTree(Tree $tree, AbstractModule $module, int $now, bool $force): string { $include_anniversaries = Configuration::includeAnniversaries($tree); // One number controls everything: how often the newsletter is // sent AND how far ahead each issue looks for events. Same // window applies to living birthdays/anniversaries and to the // historical (deceased) section. $window = Configuration::frequencyDays($tree); $birthdays = $this->event_query_service->upcomingBirthdays($tree, $window); $anniversaries = $include_anniversaries ? $this->event_query_service->upcomingAnniversaries($tree, $window) : null; $historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window); // Suppress entirely empty newsletters so subscribers don't get // a near-empty email on a slow fortnight. $has_content = !$birthdays->isEmpty() || ($anniversaries !== null && !$anniversaries->isEmpty()) || ($historical !== null && !$historical->isEmpty()); if (!$has_content) { Configuration::setLastSentAt($tree, $now); return sprintf('Tree "%s": nothing to report, send timestamp advanced.', $tree->name()); } $recipients = $this->collectRecipients($tree); if ($recipients === []) { return sprintf('Tree "%s": no subscribers, skipped.', $tree->name()); } // Match webtrees' own convention for system-generated email // (registration confirmations, password resets, "new version // available" notices): the From: header is the SiteUser // (SMTP_FROM_NAME), while Reply-To: points at the family-tree // contact person so replies still reach a human. If the site // admin hasn't configured SMTP_FROM_NAME we fall back to the // tree contact for From: too, otherwise the transport may // reject the message for lacking a sender envelope. $reply_to = $this->siteContact($tree); $from = $this->siteFrom($reply_to); $original_locale = I18N::languageTag(); $groups = $this->groupRecipientsByLanguage($recipients); $avatars = $this->collectAvatars($birthdays, $anniversaries, $historical); $featured = $this->collectFeaturedIndividuals($birthdays, $anniversaries, $historical); // The intro paragraph is attributed to the tree contact user // (the same person we use as Reply-To). If they're linked to an // Individual record we fold their avatar into the embed set so // the editorial block can render with their face on the left. // We only bother when at least one locale actually has an intro // to send — otherwise loading/encoding the portrait is wasted // work and the bytes would be attached to every recipient's // email without ever being referenced. $has_any_intro = false; foreach (array_keys(Configuration::supportedSubjectLocales()) as $code) { if (trim(Configuration::introForLocale($module, $tree, $code)) !== '') { $has_any_intro = true; break; } } $intro_author = $has_any_intro ? $this->resolveIntroAuthor($tree, $reply_to) : null; if ($intro_author instanceof Individual && !isset($avatars[$this->avatarCidName($intro_author->xref())])) { $author_avatar = $this->resolveAvatar($intro_author); if ($author_avatar !== null) { $avatars[$this->avatarCidName($intro_author->xref())] = $author_avatar; } } $avatar_cids = $this->avatarCids($avatars); $account_url = route( \Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit::class, ['tree' => $tree->name()], ); $sent = 0; $failures = 0; try { foreach ($groups as $lang => $group) { I18N::init($lang); $subject = Configuration::subjectPrefixForLocale($tree, $lang) . I18N::translate( 'Family newsletter — %s', $this->formatSubjectDate($now, $lang), ); // One-shot intro paragraph (admin-supplied). Empty // string is "no intro" — the view simply omits the // block. We do NOT clear it after sending; instead // we version-stamp it and track each recipient's // last-seen version, so subscribers on slower // cadences still get the message exactly once. $intro = Configuration::introForLocale($module, $tree, $lang); $intro_version = Configuration::introVersion($module, $tree, $lang); $external_seen = Configuration::externalIntroVersion($module, $tree, $lang); $external_served_this_run = false; // Render the email body per recipient — the relationship // labels are personalised relative to whichever individual // record the recipient is linked to in this tree. foreach ($group as $recipient) { if (!$this->recipientIsDue($tree, $recipient, $now, $force)) { continue; } $relationships = $this->relationshipMap($tree, $recipient, $featured); $detailed_set = $this->detailedXrefs($tree, $recipient, $featured); // Trim the embedded image set to only the avatars // we'll actually reference (detailed rows). Summary // bullets render without pictures. $recipient_avatars = array_intersect_key( $avatars, $this->avatarKeysForXrefs(array_keys($detailed_set)), ); // Decide whether to attach the intro for *this* // recipient: only if it's non-empty AND this // recipient hasn't yet received the current // version. Webtrees users have their own // watermark; external addresses share a single // tree-level one. $recipient_seen = $recipient instanceof User ? Configuration::userIntroVersion($tree, $recipient, $lang) : $external_seen; $show_intro = $intro !== '' && $intro_version > $recipient_seen; // Only attach the editorial portrait to recipients // who actually see the intro — otherwise the bytes // would ride along uselessly and bloat the message. if ($show_intro && $intro_author instanceof Individual) { $author_cid = $this->avatarCidName($intro_author->xref()); if (isset($avatars[$author_cid])) { $recipient_avatars[$author_cid] = $avatars[$author_cid]; } } $personalised_intro = $show_intro ? $this->renderIntroTemplate($intro, $recipient) : ''; $html = view($module->name() . '::email', [ 'tree' => $tree, 'birthdays' => $birthdays, 'anniversaries' => $anniversaries, 'historical' => $historical, 'include_anniversaries' => $include_anniversaries, 'window_days' => $window, 'generated_at' => $now, 'avatar_cids' => $avatar_cids, 'relationships' => $relationships, 'detailed_xrefs' => $detailed_set, 'account_url' => $account_url, 'intro' => $personalised_intro, 'intro_author' => $show_intro ? $intro_author : null, ]); $text = $this->htmlToText($html); try { if ($this->mailer->sendWithEmbeds($from, $recipient, $reply_to, $subject, $text, $html, $recipient_avatars)) { $sent++; // Per-user last-sent is stored only for real // webtrees users (id > 0). External admin- // added addresses always fire on every run. if ($recipient instanceof User) { Configuration::setUserLastSentAt($tree, $recipient, $now); // Mark this user as up-to-date on the // intro so we don't re-deliver it next // time their cadence comes round. if ($show_intro) { Configuration::setUserIntroVersion($tree, $recipient, $lang, $intro_version); } } elseif ($show_intro) { // External recipient — bump the single // tree-level "externals served" // watermark after the loop so all // externals in this run see the same // intro before we move it forward. $external_served_this_run = true; } } else { $failures++; } } catch (RfcComplianceException | TransportExceptionInterface $ex) { $failures++; Log::addErrorLog('Newsletter send to ' . $recipient->email() . ' failed: ' . $ex->getMessage()); } } if ($external_served_this_run) { Configuration::setExternalIntroVersion($module, $tree, $lang, $intro_version); } } } finally { // Always restore the original locale, even if a render or // send throws an unexpected exception. I18N::init($original_locale); } // Tree-level last-sent is kept for the admin "Last sent" line // on the preferences page; it no longer gates dispatch. if ($sent > 0 || $failures > 0) { Configuration::setLastSentAt($tree, $now); } return sprintf( 'Tree "%s": sent to %d recipient(s), %d failure(s).', $tree->name(), $sent, $failures, ); } /** * True if the recipient's own cadence has elapsed since their * last newsletter (or if `$force` is set). External non-user * recipients have no per-user timestamp — they always fire so * admin-managed mailing lists still get an issue every run. */ private function recipientIsDue(Tree $tree, UserInterface $recipient, int $now, bool $force): bool { if ($force) { return true; } if (!$recipient instanceof User) { return true; } $last = Configuration::userLastSentAt($tree, $recipient); $freq = Configuration::effectiveFrequencyDays($tree, $recipient); $due_at = $last + $freq * 86400; return $now >= $due_at; } /** * Resolve a "highlighted" image for every individual mentioned in * the newsletter and return a CID-keyed map of bytes + MIME type * that NewsletterMailer can embed. * * @param Collection|null $anniversaries * @param Collection|null $historical * * @return array */ /** * Set of Individual records that appear in any of the newsletter * sections. Used by the relationship-map builder so each row can * be labelled "your mother", "3rd cousin once removed", etc. * * @param Collection|null $anniversaries * @param Collection|null $historical * * @return array xref => Individual */ private function collectFeaturedIndividuals( Collection $birthdays, Collection|null $anniversaries, Collection|null $historical, ): array { $individuals = []; foreach ($birthdays as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } if ($anniversaries !== null) { foreach ($anniversaries as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } } if ($historical !== null) { foreach ($historical as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } } return $individuals; } /** * Which featured xrefs deserve the full detailed row (avatar + * timeline) for this recipient? * * If the recipient is unmapped to the tree (external address or a * user with no PREF_TREE_ACCOUNT_XREF), every featured xref counts * as "detailed" — they have no lineal context to filter against. * * Otherwise, only the recipient's direct ancestors and descendants * within Configuration::linealDepth() generations qualify. For * Family records (anniversaries), either spouse being lineal * promotes the row. * * @param array $featured * * @return array Set of featured xrefs to render in detail. */ private function detailedXrefs(Tree $tree, UserInterface $recipient, array $featured): array { $self_xref = $tree->getUserPreference($recipient, UserInterface::PREF_TREE_ACCOUNT_XREF); if ($self_xref === '') { // Unmapped recipient — no lineal anchor, show everything. return array_fill_keys(array_keys($featured), true); } $self = Registry::individualFactory()->make($self_xref, $tree); if (!$self instanceof Individual) { return array_fill_keys(array_keys($featured), true); } $max_distance = Configuration::linealDepth($tree); $distances = $this->relationship_finder->kinDistances($self, $max_distance); $detailed = []; foreach ($featured as $xref => $_individual) { if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) { $detailed[$xref] = true; } } return $detailed; } /** * Translates a list of xrefs into the cid-name keys used by the * avatar embed map ("avatar-" => true). Lets us * array_intersect_key the embeds map cheaply. * * @param array $xrefs * @return array */ private function avatarKeysForXrefs(array $xrefs): array { $keys = []; foreach ($xrefs as $xref) { $keys['avatar-' . $xref] = true; } return $keys; } /** * Build a "xref => relationship label" map for one recipient. * * Returns an empty map for recipients we cannot label: external * addresses (no webtrees account), users with no linked Individual * record on this tree, or users whose linked record can't be * resolved (privacy-hidden, broken xref). * * @param array $featured * * @return array */ private function relationshipMap(Tree $tree, UserInterface $recipient, array $featured): array { $self_xref = $tree->getUserPreference($recipient, 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; } private function collectAvatars( Collection $birthdays, Collection|null $anniversaries, Collection|null $historical, ): array { $individuals = []; foreach ($birthdays as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } if ($anniversaries !== null) { foreach ($anniversaries as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } } if ($historical !== null) { foreach ($historical as $fact) { $this->indexIndividualsFromFact($fact, $individuals); } } $avatars = []; foreach ($individuals as $xref => $individual) { $payload = $this->resolveAvatar($individual); if ($payload !== null) { $avatars[$this->avatarCidName($xref)] = $payload; } } return $avatars; } /** * @param array $bag */ private function indexIndividualsFromFact(Fact $fact, array &$bag): void { $record = $fact->record(); if ($record instanceof Individual) { $bag[$record->xref()] = $record; return; } if ($record instanceof Family) { foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $bag[$spouse->xref()] = $spouse; } } } } /** * @return array{bytes:string,mime:string}|null */ private function resolveAvatar(Individual $individual): array|null { try { $media_file = $individual->findHighlightedMediaFile(); } catch (Throwable $ex) { Log::addErrorLog('Newsletter avatar lookup failed for ' . $individual->xref() . ': ' . $ex->getMessage()); return null; } if ($media_file === null) { return null; } // External URLs cannot be embedded as inline parts. Skipping // these gracefully falls back to the placeholder silhouette. if ($media_file->isExternal() || !$media_file->isImage()) { return null; } try { $bytes = $media_file->fileContents(); } catch (Throwable $ex) { Log::addErrorLog('Newsletter avatar read failed for ' . $individual->xref() . ': ' . $ex->getMessage()); return null; } if ($bytes === '') { return null; } $resized = $this->resizeAvatar($bytes); if ($resized !== null) { return $resized; } // Fall back to the original bytes if no image library is // available — better an oversized email than no avatar at all. return [ 'bytes' => $bytes, 'mime' => $media_file->mimeType() ?: 'application/octet-stream', ]; } /** * Cover-crop the source image to a small square and re-encode it * as JPEG. Drops a multi-MB source down to ~5–15 KB. * * Returns null if no image library is loaded (in which case the * caller keeps the original bytes), or if Intervention failed to * read the file. * * @return array{bytes:string,mime:string}|null */ private function resizeAvatar(string $bytes): array|null { $manager = $this->imageManager(); if ($manager === null) { return null; } try { $image = $manager->read($bytes); $encoded = $image ->cover(self::AVATAR_SIZE, self::AVATAR_SIZE) ->encode(new JpegEncoder(self::AVATAR_JPEG_QUALITY)) ->toString(); } catch (Throwable $ex) { Log::addErrorLog('Newsletter avatar resize failed: ' . $ex->getMessage()); return null; } return [ 'bytes' => $encoded, 'mime' => 'image/jpeg', ]; } /** * Lazily build (and cache) an Intervention ImageManager, preferring * Imagick over GD — mirrors what webtrees' own ImageFactory does. * Returns null if neither extension is present so the caller can * gracefully fall back to the original image bytes. */ private function imageManager(): ImageManager|null { if (!$this->image_manager_resolved) { $this->image_manager_resolved = true; if (extension_loaded('imagick')) { $this->image_manager = new ImageManager(new ImagickDriver()); } elseif (extension_loaded('gd')) { $this->image_manager = new ImageManager(new GdDriver()); } else { Log::addErrorLog( 'Newsletter: neither Imagick nor GD is available; avatars will be embedded at their original size.', ); } } return $this->image_manager; } /** * Build the map the view consults: xref -> CID name. Only entries * for individuals with a successfully resolved avatar are present; * the view treats absence as "use the placeholder". * * @param array $avatars * @return array */ private function avatarCids(array $avatars): array { $cids = []; foreach (array_keys($avatars) as $cid_name) { // CID name is "avatar-{xref}" — reverse the prefix to recover the xref. $cids[substr($cid_name, strlen('avatar-'))] = $cid_name; } return $cids; } private function avatarCidName(string $xref): string { return 'avatar-' . $xref; } /** * Group recipients by the language we will render their email in. * German users get "de"; everyone else (including admin-added * external addresses) gets "en-US" for now. * * @param array $recipients * @return array> */ private function groupRecipientsByLanguage(array $recipients): array { $groups = []; foreach ($recipients as $recipient) { $pref = $recipient->getPreference(UserInterface::PREF_LANGUAGE, ''); $lang = str_starts_with(strtolower($pref), 'de') ? 'de' : 'en-US'; $groups[$lang][] = $recipient; } return $groups; } /** * @return array */ private function collectRecipients(Tree $tree): array { $recipients = []; $seen = []; foreach ($this->user_service->all() as $user) { if (!$user instanceof User) { continue; } // Only deliver to approved + email-verified accounts. if ($user->getPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') { continue; } if ($user->getPreference(UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') { continue; } if ($tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) !== '1') { continue; } $email = trim($user->email()); if ($email === '' || isset($seen[strtolower($email)])) { continue; } $seen[strtolower($email)] = true; $recipients[] = $user; } foreach (Configuration::extraRecipients($tree) as $email) { $key = strtolower($email); if (isset($seen[$key]) || !$this->mailer->isValidEmail($email)) { continue; } $seen[$key] = true; $recipients[] = new ExtraRecipient($email); } return $recipients; } /** * Resolve the From: identity, mirroring webtrees' own behaviour: * the SMTP_FROM_NAME / SMTP_DISP_NAME site preferences if set, * otherwise the tree contact user (so the transport always has * a usable envelope sender). This is the address recipients see * in their mail client, not the one their replies go to. */ private function siteFrom(UserInterface $fallback): UserInterface { if (trim(Site::getPreference('SMTP_FROM_NAME')) !== '') { return new SiteUser(); } return $fallback; } /** * Pick a sender identity. Fall back to a synthetic UserInterface if * the site has no contact user configured for this tree. */ private function siteContact(Tree $tree): UserInterface { $contact_id = (int) $tree->getPreference('CONTACT_USER_ID'); if ($contact_id !== 0) { $user = $this->user_service->find($contact_id); if ($user !== null && trim($user->email()) !== '') { return $user; } } $webmaster_id = (int) \Fisharebest\Webtrees\Site::getPreference('WEBMASTER_USER_ID'); if ($webmaster_id !== 0) { $user = $this->user_service->find($webmaster_id); if ($user !== null && trim($user->email()) !== '') { return $user; } } // Last resort: derive a sender address from the server hostname. // The mail transport must accept this; admins will normally have // a contact user set, so this only matters for fresh installs. $host = $_SERVER['SERVER_NAME'] ?? 'localhost'; $address = 'no-reply@' . preg_replace('/[^a-zA-Z0-9.\-]/', '', $host); return new ExtraRecipient($address, self::FROM_NAME); } /** * Format the issue date for the email subject in the recipient's * locale: "15. Mai 2026" for de, "May 15, 2026" for en-US. Falls * back to the English date() format if ext-intl is unavailable — * webtrees lists intl as required so this should not happen in * practice, but we don't want to fatal a dispatch over it. */ private function formatSubjectDate(int $timestamp, string $language): string { if (class_exists(IntlDateFormatter::class)) { $formatter = IntlDateFormatter::create( str_replace('-', '_', $language), IntlDateFormatter::LONG, IntlDateFormatter::NONE, ); if ($formatter instanceof IntlDateFormatter) { $formatted = $formatter->format($timestamp); if (is_string($formatted) && $formatted !== '') { return $formatted; } } } return date('F j, Y', $timestamp); } /** * The intro paragraph is signed by the tree contact user — the * same person webtrees uses for replies. If they have a linked * Individual record on this tree, return it so the email view * can render their avatar beside the intro; otherwise return * null and the view falls back to a single-column layout. */ private function resolveIntroAuthor(Tree $tree, UserInterface $author): Individual|null { if (!$author instanceof User) { return null; } $xref = $tree->getUserPreference($author, UserInterface::PREF_TREE_ACCOUNT_XREF); if ($xref === '') { return null; } $individual = Registry::individualFactory()->make($xref, $tree); return $individual instanceof Individual ? $individual : null; } /** * Replace `{{first_name}}`, `{{last_name}}`, `{{username}}` and * `{{email}}` placeholders in the admin-supplied intro with values * from the current recipient. * * Webtrees users only have a single `realName()` field; we split * on the first whitespace run to derive first/last. External * (non-user) recipients fall through with their email in place of * a name — they have no username, so `{{username}}` resolves to * an empty string for them. */ private function renderIntroTemplate(string $intro, UserInterface $recipient): string { if ($intro === '' || !str_contains($intro, '{{')) { return $intro; } $real_name = trim($recipient->realName()); $parts = $real_name === '' ? [] : preg_split('/\s+/', $real_name, 2); $first = $parts[0] ?? ''; $last = $parts[1] ?? ''; return strtr($intro, [ '{{first_name}}' => $first, '{{last_name}}' => $last, '{{username}}' => $recipient->userName(), '{{email}}' => $recipient->email(), ]); } private function htmlToText(string $html): string { $without_tags = preg_replace('/<\s*br\s*\/?>/i', "\n", $html) ?? $html; $without_tags = preg_replace('/<\/(p|div|li|tr|h[1-6])>/i', "\n", $without_tags) ?? $without_tags; $stripped = strip_tags($without_tags); $decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Collapse runs of blank lines. return trim(preg_replace("/\n{3,}/", "\n\n", $decoded) ?? $decoded); } }