+
+ = I18N::translate('Each issue looks the same number of days ahead, for both living relatives and historical events of those who have passed away. Default 14.') ?>
+
@@ -116,20 +101,6 @@ use Illuminate\Support\Collection;
-
-
-
-
-
- = I18N::translate('Births and deaths of deceased people are included once per calendar month.') ?>
-
-
-
-
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 = '
'
= e($masthead_date($generated_at)) ?>
·
- = e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
+ = e(I18N::translate('Events in the next %d days.', $window_days)) ?>
@@ -706,7 +704,7 @@ $timeline_arrow_row = '
'
- isEmpty()) : ?>
+ isEmpty()) : ?>
'
= e(I18N::translate('On this month in history')) ?>
- = e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
+ = e(I18N::translate('Events in the next %d days for people who have passed away.', $window_days)) ?>
'
= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
= I18N::translate(
- 'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
+ 'You can change how often you receive this email, or unsubscribe entirely, in the “Newsletter subscription” section on your %s page.',
'' . 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