From 00478e2466ec79935efbf041818f7968ab032505 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 14:12:39 +0200 Subject: [PATCH] Single frequency setting; per-user override; footer line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-facing simplification: - Dropped separate \"lookahead\" and \"historical lookahead\" tree prefs (and the once-per-month historical gate). A single \"send every N days\" number now drives both the cron cadence and the window each issue looks ahead for living + deceased events. - Default 14, range 1–90, applies uniformly. User-facing addition: - The /my-account/{tree} subscription card gained an \"Email frequency\" select with options: use site default, weekly, every 2 weeks, monthly, every 2 months, quarterly. Stored as a per-tree-per-user preference. - Dispatch now checks each recipient's own cadence against their own last-sent timestamp. Admin-added external addresses with no webtrees account always receive every run (no per-user state). - Newsletter footer now reads \"You can change how often you receive this email, or unsubscribe entirely, in the Newsletter subscription section on your My account page\" — true now that the control exists. German translations updated for the new strings; stale ones removed. --- resources/views/admin.phtml | 35 +-------- resources/views/edit-account-page.phtml | 26 +++++++ resources/views/email.phtml | 12 ++- src/Configuration.php | 87 +++++++++++++--------- src/Http/AccountUpdateDecorator.php | 14 ++++ src/Module.php | 25 +++---- src/Services/NewsletterDispatchService.php | 85 ++++++++++++--------- 7 files changed, 162 insertions(+), 122 deletions(-) diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml index c1237ab..fe4e5ec 100644 --- a/resources/views/admin.phtml +++ b/resources/views/admin.phtml @@ -42,8 +42,6 @@ use Illuminate\Support\Collection; $id = $tree->id(); $enabled = Configuration::isEnabled($tree); $frequency = Configuration::frequencyDays($tree); - $lookahead = Configuration::lookaheadDays($tree); - $histLook = Configuration::historicalLookaheadDays($tree); $annivs = Configuration::includeAnniversaries($tree); $subject = Configuration::subjectPrefix($tree); $extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, ''); @@ -81,22 +79,9 @@ use Illuminate\Support\Collection; max="" required> - - - -
- -
-
- - -
+ + +
@@ -116,20 +101,6 @@ use Illuminate\Support\Collection; -
- -
- - - - -
-
-
diff --git a/resources/views/email.phtml b/resources/views/email.phtml index a79f14f..2c28d0e 100644 --- a/resources/views/email.phtml +++ b/resources/views/email.phtml @@ -16,9 +16,7 @@ use Illuminate\Support\Collection; * @var Collection|null $anniversaries * @var Collection|null $historical * @var bool $include_anniversaries - * @var bool $include_historical - * @var int $lookahead_days - * @var int $historical_lookahead + * @var int $window_days Shared lookahead window for living + deceased events * @var int $generated_at * @var array $avatar_cids xref => CID name * @var array $relationships xref => "your mother" etc. (per-recipient) @@ -597,7 +595,7 @@ $timeline_arrow_row = ''
· - +
@@ -706,7 +704,7 @@ $timeline_arrow_row = '' - isEmpty()) : ?> + isEmpty()) : ?> '

- +

@@ -764,7 +762,7 @@ $timeline_arrow_row = '' title())) ?>
' . e(I18N::translate('My account')) . '', ) ?>

diff --git a/src/Configuration.php b/src/Configuration.php index 1bc4eb5..fe3fb33 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -9,9 +9,9 @@ use Fisharebest\Webtrees\Tree; /** * Per-tree configuration for the Email Newsletter module. * - * All values are persisted via $tree->setPreference()/getPreference(). - * State values (last-sent timestamp, last historical-section month) are - * also stored on the tree because each tree produces its own newsletter. + * All values are persisted via $tree->setPreference() / getPreference(). + * State values (last-sent timestamp) are also stored on the tree because + * each tree produces its own newsletter on its own cadence. */ final class Configuration { @@ -19,29 +19,25 @@ final class Configuration // column. Keys here MUST stay <= 32 characters. public const string PREF_ENABLED = 'NEWSLETTER_ENABLED'; public const string PREF_FREQUENCY_DAYS = 'NEWSLETTER_FREQ_DAYS'; - public const string PREF_LOOKAHEAD_DAYS = 'NEWSLETTER_LOOK_DAYS'; public const string PREF_INCLUDE_ANNIVERSARIES = 'NEWSLETTER_INC_ANNIVS'; - public const string PREF_HISTORICAL_LOOKAHEAD = 'NEWSLETTER_HIST_DAYS'; public const string PREF_EXTRA_RECIPIENTS = 'NEWSLETTER_EXTRAS'; public const string PREF_LINEAL_DEPTH = 'NEWSLETTER_LINEAL_DEPTH'; public const string PREF_LAST_SENT_AT = 'NEWSLETTER_LAST_SENT'; - public const string PREF_LAST_HISTORICAL_MONTH = 'NEWSLETTER_LAST_HIST_MO'; public const string PREF_SUBJECT_PREFIX = 'NEWSLETTER_SUBJ_PFX'; // Module-level (not tree-bound) settings. public const string MODULE_PREF_CRON_TOKEN = 'cron_token'; - // Per-user subscription preference. Set via $user->setPreference(). + // Per-tree-per-user preferences. Stored via + // $tree->setUserPreference($user, ...). public const string USER_PREF_SUBSCRIBED = 'newsletter_subscribed'; + public const string USER_PREF_FREQUENCY_DAYS = 'NEWSLETTER_USER_FREQ_DAYS'; + public const string USER_PREF_LAST_SENT_AT = 'NEWSLETTER_USER_LAST_SENT'; public const int DEFAULT_FREQUENCY_DAYS = 14; - public const int DEFAULT_LOOKAHEAD_DAYS = 14; - public const int DEFAULT_HISTORICAL_LOOKAHEAD = 30; public const int DEFAULT_LINEAL_DEPTH = 3; public const int MIN_FREQUENCY_DAYS = 1; public const int MAX_FREQUENCY_DAYS = 90; - public const int MIN_LOOKAHEAD_DAYS = 1; - public const int MAX_LOOKAHEAD_DAYS = 60; public const int MIN_LINEAL_DEPTH = 0; public const int MAX_LINEAL_DEPTH = 10; @@ -50,6 +46,12 @@ final class Configuration return $tree->getPreference(self::PREF_ENABLED) === '1'; } + /** + * The single cadence number: newsletters are sent every N days + * and each issue looks N days ahead for events. Used as both the + * send-interval and the look-ahead window for living and + * deceased relatives alike. + */ public static function frequencyDays(Tree $tree): int { $value = (int) $tree->getPreference(self::PREF_FREQUENCY_DAYS, (string) self::DEFAULT_FREQUENCY_DAYS); @@ -57,13 +59,6 @@ final class Configuration return max(self::MIN_FREQUENCY_DAYS, min(self::MAX_FREQUENCY_DAYS, $value)); } - public static function lookaheadDays(Tree $tree): int - { - $value = (int) $tree->getPreference(self::PREF_LOOKAHEAD_DAYS, (string) self::DEFAULT_LOOKAHEAD_DAYS); - - return max(self::MIN_LOOKAHEAD_DAYS, min(self::MAX_LOOKAHEAD_DAYS, $value)); - } - public static function includeAnniversaries(Tree $tree): bool { return $tree->getPreference(self::PREF_INCLUDE_ANNIVERSARIES, '1') === '1'; @@ -76,14 +71,48 @@ final class Configuration return max(self::MIN_LINEAL_DEPTH, min(self::MAX_LINEAL_DEPTH, $value)); } - public static function historicalLookaheadDays(Tree $tree): int + /** + * The set of user-selectable cadences, in days. 0 is a sentinel + * meaning "fall back to the tree's frequency". Other values are + * weekly / biweekly / monthly / bimonthly / quarterly. + * + * @return array + */ + public static function userFrequencyOptions(): array { - $value = (int) $tree->getPreference( - self::PREF_HISTORICAL_LOOKAHEAD, - (string) self::DEFAULT_HISTORICAL_LOOKAHEAD, - ); + return [0, 7, 14, 30, 60, 90]; + } - return max(7, min(60, $value)); + public static function userFrequencyDays(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int + { + $raw = (int) $tree->getUserPreference($user, self::USER_PREF_FREQUENCY_DAYS, '0'); + + if (!in_array($raw, self::userFrequencyOptions(), true)) { + return 0; + } + + return $raw; + } + + /** + * Resolved cadence for a recipient: the user's choice if they + * picked one, otherwise the tree default. + */ + public static function effectiveFrequencyDays(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int + { + $user_pref = self::userFrequencyDays($tree, $user); + + return $user_pref > 0 ? $user_pref : self::frequencyDays($tree); + } + + public static function userLastSentAt(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user): int + { + return (int) $tree->getUserPreference($user, self::USER_PREF_LAST_SENT_AT, '0'); + } + + public static function setUserLastSentAt(Tree $tree, \Fisharebest\Webtrees\Contracts\UserInterface $user, int $timestamp): void + { + $tree->setUserPreference($user, self::USER_PREF_LAST_SENT_AT, (string) $timestamp); } public static function subjectPrefix(Tree $tree): string @@ -114,14 +143,4 @@ final class Configuration { $tree->setPreference(self::PREF_LAST_SENT_AT, (string) $timestamp); } - - public static function lastHistoricalMonth(Tree $tree): string - { - return $tree->getPreference(self::PREF_LAST_HISTORICAL_MONTH, ''); - } - - public static function setLastHistoricalMonth(Tree $tree, string $yearMonth): void - { - $tree->setPreference(self::PREF_LAST_HISTORICAL_MONTH, $yearMonth); - } } diff --git a/src/Http/AccountUpdateDecorator.php b/src/Http/AccountUpdateDecorator.php index 2d36247..08fa6fe 100644 --- a/src/Http/AccountUpdateDecorator.php +++ b/src/Http/AccountUpdateDecorator.php @@ -49,6 +49,20 @@ final class AccountUpdateDecorator implements RequestHandlerInterface Configuration::USER_PREF_SUBSCRIBED, $subscribed ? '1' : '0', ); + + // Newsletter frequency: only persist a value the user + // actually picked from the offered options (0 = "use site + // default"). Anything else is silently dropped so a + // malformed POST can't pin them on an arbitrary cadence. + $frequency = Validator::parsedBody($request) + ->isInArray(Configuration::userFrequencyOptions()) + ->integer('newsletter_frequency', 0); + + $tree->setUserPreference( + $user, + Configuration::USER_PREF_FREQUENCY_DAYS, + (string) $frequency, + ); } return $response; diff --git a/src/Module.php b/src/Module.php index c08ea76..7121ae4 100644 --- a/src/Module.php +++ b/src/Module.php @@ -108,9 +108,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf 'Subscribe to the newsletter' => 'Newsletter abonnieren', 'Send newsletters every' => 'Newsletter senden alle', 'days' => 'Tage', - 'Look ahead' => 'Vorschau', + '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', - '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', @@ -141,15 +141,20 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf => '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.', + '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.', - '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.', @@ -231,12 +236,6 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf $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); $lineal = Validator::parsedBody($request) ->isBetween(Configuration::MIN_LINEAL_DEPTH, Configuration::MAX_LINEAL_DEPTH) ->integer('lineal-' . $id, Configuration::DEFAULT_LINEAL_DEPTH); @@ -246,8 +245,6 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf $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_LINEAL_DEPTH, (string) $lineal); $tree->setPreference(Configuration::PREF_INCLUDE_ANNIVERSARIES, $annivs ? '1' : '0'); $tree->setPreference(Configuration::PREF_EXTRA_RECIPIENTS, $extras); diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index 0e4ca73..e62d483 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -86,18 +86,9 @@ final class NewsletterDispatchService continue; } - $due_at = Configuration::lastSentAt($tree) - + Configuration::frequencyDays($tree) * 86400; - - if (!$force && $now < $due_at) { - $log[] = sprintf( - 'Tree "%s": not due yet (next send in %d hours).', - $tree->name(), - (int) (($due_at - $now) / 3600), - ); - 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); } @@ -107,25 +98,18 @@ final class NewsletterDispatchService private function dispatchForTree(Tree $tree, ModuleInterface $module, int $now, bool $force): string { $include_anniversaries = Configuration::includeAnniversaries($tree); - $lookahead = Configuration::lookaheadDays($tree); - $historical_lookahead = Configuration::historicalLookaheadDays($tree); - $current_month = date('Y-m', $now); - // Normally the historical section only appears on the first - // scheduled send of each calendar month. Forced sends (admin - // hitting "Send now" to preview the newsletter) always include - // it — otherwise re-clicking the button silently strips the - // section after the first run of the month. - $include_historical = $force - || Configuration::lastHistoricalMonth($tree) !== $current_month; + // 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, $lookahead); + $birthdays = $this->event_query_service->upcomingBirthdays($tree, $window); $anniversaries = $include_anniversaries - ? $this->event_query_service->upcomingAnniversaries($tree, $lookahead) - : null; - $historical = $include_historical - ? $this->event_query_service->upcomingHistoricalEvents($tree, $historical_lookahead) + ? $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. @@ -172,6 +156,10 @@ final class NewsletterDispatchService // 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); @@ -189,9 +177,7 @@ final class NewsletterDispatchService 'anniversaries' => $anniversaries, 'historical' => $historical, 'include_anniversaries' => $include_anniversaries, - 'include_historical' => $include_historical, - 'lookahead_days' => $lookahead, - 'historical_lookahead' => $historical_lookahead, + 'window_days' => $window, 'generated_at' => $now, 'avatar_cids' => $avatar_cids, 'relationships' => $relationships, @@ -204,6 +190,12 @@ final class NewsletterDispatchService try { if ($this->mailer->sendWithEmbeds($from, $recipient, $from, $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); + } } else { $failures++; } @@ -219,20 +211,43 @@ final class NewsletterDispatchService I18N::init($original_locale); } - Configuration::setLastSentAt($tree, $now); - if ($include_historical) { - Configuration::setLastHistoricalMonth($tree, $current_month); + // 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)%s.', + 'Tree "%s": sent to %d recipient(s), %d failure(s).', $tree->name(), $sent, $failures, - $include_historical ? ', monthly historical section included' : '', ); } + /** + * 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