90ad060421
Replaces "clear intro after first send", which dropped the message for any subscriber still queued on a slower cadence. - Each non-empty admin save bumps a per-locale version counter on the tree. The dispatcher includes the intro only for recipients whose last-seen version is behind, then advances their watermark after a successful send. Webtrees users get a per-user watermark; external addresses share one tree-level watermark. Re-saving the same text is a no-op. - The preferences page now shows delivery progress per locale: "Delivered to X of Y subscriber(s)" plus a collapsible Pending list with name + email of each subscriber who hasn't received the current intro yet, and a single "External recipients (N)" row when the external watermark is behind. - README rewritten to reflect every feature shipped since the initial commit (BockenTheme skin, embedded avatars, relationship labels, kin-distance filter, per-user cadence, bilingual subject prefix, locale-aware subject date, SiteUser as From, three subscriber sources, Markdown intro with personalisation tokens).
358 lines
20 KiB
PHTML
358 lines
20 KiB
PHTML
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use EmailNewsletter\Configuration;
|
||
use EmailNewsletter\Module;
|
||
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
|
||
use Fisharebest\Webtrees\Http\RequestHandlers\ModulesAllPage;
|
||
use Fisharebest\Webtrees\I18N;
|
||
use Fisharebest\Webtrees\Tree;
|
||
use Fisharebest\Webtrees\User;
|
||
use Illuminate\Support\Collection;
|
||
|
||
/**
|
||
* @var Module $module
|
||
* @var Collection<int,Tree> $all_trees
|
||
* @var Collection<int,User> $all_users
|
||
* @var string $cron_token
|
||
* @var string $cron_url
|
||
* @var string $title
|
||
*/
|
||
|
||
?>
|
||
|
||
<?= view('components/breadcrumbs', [
|
||
'links' => [
|
||
route(ControlPanel::class) => I18N::translate('Control panel'),
|
||
route(ModulesAllPage::class) => I18N::translate('Modules'),
|
||
$title,
|
||
],
|
||
]) ?>
|
||
|
||
<h1><?= e($title) ?></h1>
|
||
|
||
<p>
|
||
<?= I18N::translate('Configure newsletter dispatch on a per-tree basis. The sender is the contact user of each tree (falling back to the site webmaster).') ?>
|
||
</p>
|
||
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
|
||
<?php foreach ($all_trees as $tree) : ?>
|
||
<?php
|
||
$id = $tree->id();
|
||
$enabled = Configuration::isEnabled($tree);
|
||
$frequency = Configuration::frequencyDays($tree);
|
||
$annivs = Configuration::includeAnniversaries($tree);
|
||
$subject = Configuration::subjectPrefix($tree);
|
||
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
|
||
$lineal = Configuration::linealDepth($tree);
|
||
$last_sent = Configuration::lastSentAt($tree);
|
||
?>
|
||
|
||
<fieldset class="card mb-4">
|
||
<legend class="card-header h4"><?= e($tree->title()) ?></legend>
|
||
|
||
<div class="card-body">
|
||
<div class="row mb-3">
|
||
<div class="offset-sm-3 col-sm-9">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox"
|
||
id="enabled-<?= $id ?>" name="enabled-<?= $id ?>"
|
||
value="1" <?= $enabled ? 'checked' : '' ?>>
|
||
<label class="form-check-label" for="enabled-<?= $id ?>">
|
||
<?= I18N::translate('Enable newsletter for this tree') ?>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-3">
|
||
<label class="col-sm-3 col-form-label" for="frequency-<?= $id ?>">
|
||
<?= I18N::translate('Send newsletters every') ?>
|
||
</label>
|
||
<div class="col-sm-9">
|
||
<div class="input-group" style="max-width: 18rem;">
|
||
<input class="form-control" type="number"
|
||
id="frequency-<?= $id ?>" name="frequency-<?= $id ?>"
|
||
value="<?= e((string) $frequency) ?>"
|
||
min="<?= Configuration::MIN_FREQUENCY_DAYS ?>"
|
||
max="<?= Configuration::MAX_FREQUENCY_DAYS ?>" required>
|
||
<span class="input-group-text"><?= I18N::translate('days') ?></span>
|
||
</div>
|
||
<small class="form-text text-muted">
|
||
<?= 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.') ?>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-3">
|
||
<div class="offset-sm-3 col-sm-9">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox"
|
||
id="anniversaries-<?= $id ?>" name="anniversaries-<?= $id ?>"
|
||
value="1" <?= $annivs ? 'checked' : '' ?>>
|
||
<label class="form-check-label" for="anniversaries-<?= $id ?>">
|
||
<?= I18N::translate('Include marriage anniversaries') ?>
|
||
</label>
|
||
</div>
|
||
<small class="form-text text-muted">
|
||
<?= I18N::translate('Only intact marriages of still-living couples are included.') ?>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mb-3">
|
||
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
|
||
<?= I18N::translate('Detailed view distance') ?>
|
||
</label>
|
||
<div class="col-sm-9">
|
||
<input class="form-control" type="number" style="max-width: 18rem;"
|
||
id="lineal-<?= $id ?>" name="lineal-<?= $id ?>"
|
||
value="<?= e((string) $lineal) ?>"
|
||
min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
|
||
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
|
||
<small class="form-text text-muted">
|
||
<?= I18N::translate('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.') ?>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<fieldset class="row mb-3">
|
||
<legend class="col-sm-3 col-form-label">
|
||
<?= I18N::translate('Subject prefix') ?>
|
||
</legend>
|
||
<div class="col-sm-9">
|
||
<small class="form-text text-muted d-block mb-2">
|
||
<?= I18N::translate('Prepended to the email subject line. Leave a field blank to fall back to the generic prefix below.') ?>
|
||
</small>
|
||
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
|
||
<?php
|
||
$field = 'subject-' . $id . '-' . $code;
|
||
$val = $tree->getPreference(
|
||
Configuration::PREF_SUBJECT_PREFIX_PREFIX . $code,
|
||
'',
|
||
);
|
||
?>
|
||
<div class="input-group input-group-sm mb-2">
|
||
<span class="input-group-text" style="min-width: 7rem;">
|
||
<?= e($label) ?>
|
||
</span>
|
||
<input class="form-control" type="text"
|
||
id="<?= e($field) ?>" name="<?= e($field) ?>"
|
||
value="<?= e($val) ?>"
|
||
placeholder="<?= e('[' . $tree->title() . '] ') ?>">
|
||
</div>
|
||
<?php endforeach ?>
|
||
|
||
<div class="input-group input-group-sm">
|
||
<span class="input-group-text" style="min-width: 7rem;">
|
||
<?= I18N::translate('Generic') ?>
|
||
</span>
|
||
<input class="form-control" type="text"
|
||
id="subject-<?= $id ?>" name="subject-<?= $id ?>"
|
||
value="<?= e($subject) ?>">
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div class="row mb-3">
|
||
<label class="col-sm-3 col-form-label" for="extras-<?= $id ?>">
|
||
<?= I18N::translate('Extra recipient email addresses (one per line)') ?>
|
||
</label>
|
||
<div class="col-sm-9">
|
||
<textarea class="form-control" rows="4"
|
||
id="extras-<?= $id ?>" name="extras-<?= $id ?>"><?= e($extras) ?></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<fieldset class="row mb-3">
|
||
<legend class="col-sm-3 col-form-label">
|
||
<?= I18N::translate('Intro paragraph for the next email') ?>
|
||
</legend>
|
||
<div class="col-sm-9">
|
||
<small class="form-text text-muted d-block mb-2">
|
||
<?= I18N::translate('Delivered once to every subscriber on their own cadence. Edit and save the text to send a new intro to everyone.') ?>
|
||
<br>
|
||
<?= I18N::translate('Formatted as Markdown — e.g. %1$s for emphasis, %2$s for a link.', '<code>**bold**</code>', '<code>[label](https://example.org)</code>') ?>
|
||
<br>
|
||
<?= I18N::translate('Personalisation tokens:') ?>
|
||
<code>{{first_name}}</code>,
|
||
<code>{{last_name}}</code>,
|
||
<code>{{username}}</code>,
|
||
<code>{{email}}</code>
|
||
</small>
|
||
<?php
|
||
// Subscribers for this tree — used by the
|
||
// per-locale "who has seen it?" block
|
||
// below. Same approved/verified gate that
|
||
// the dispatcher applies, so the admin's
|
||
// numbers match the actual run.
|
||
$tree_subscribers = $all_users->filter(static function (User $user) use ($tree): bool {
|
||
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_ACCOUNT_APPROVED) !== '1') {
|
||
return false;
|
||
}
|
||
if ($user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_IS_EMAIL_VERIFIED) !== '1') {
|
||
return false;
|
||
}
|
||
return $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
|
||
});
|
||
$external_addresses = Configuration::extraRecipients($tree);
|
||
?>
|
||
<?php foreach (Configuration::supportedSubjectLocales() as $code => $label) : ?>
|
||
<?php
|
||
$field = 'intro-' . $id . '-' . $code;
|
||
$val = Configuration::introForLocale($module, $tree, $code);
|
||
$current_v = Configuration::introVersion($module, $tree, $code);
|
||
$external_v = Configuration::externalIntroVersion($module, $tree, $code);
|
||
$locale_subs = $tree_subscribers->filter(static function (User $user) use ($code): bool {
|
||
$pref = $user->getPreference(\Fisharebest\Webtrees\Contracts\UserInterface::PREF_LANGUAGE, '');
|
||
return Configuration::canonicalSubjectLocale($pref) === $code;
|
||
});
|
||
// Status counts — only meaningful once
|
||
// the intro has been bumped to v ≥ 1.
|
||
$seen_users = 0;
|
||
$pending = [];
|
||
foreach ($locale_subs as $user) {
|
||
if (Configuration::userIntroVersion($tree, $user, $code) >= $current_v) {
|
||
$seen_users++;
|
||
} else {
|
||
$pending[] = $user;
|
||
}
|
||
}
|
||
$externals_seen = $current_v > 0 && $external_v >= $current_v;
|
||
$externals_pending = $current_v > 0 && !$externals_seen && $external_addresses !== [];
|
||
$total = $locale_subs->count() + ($external_addresses === [] ? 0 : 1);
|
||
$done = $seen_users + ($externals_seen ? 1 : 0);
|
||
?>
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted mb-1" for="<?= e($field) ?>">
|
||
<?= e($label) ?>
|
||
</label>
|
||
<textarea class="form-control" rows="6"
|
||
id="<?= e($field) ?>" name="<?= e($field) ?>"><?= e($val) ?></textarea>
|
||
|
||
<?php if ($val !== '' && $current_v > 0) : ?>
|
||
<div class="small text-muted mt-1">
|
||
<?php if ($done === $total) : ?>
|
||
<span class="text-success">✓</span>
|
||
<?= I18N::translate('Delivered to all %d subscriber(s).', $total) ?>
|
||
<?php else : ?>
|
||
<?= I18N::translate('Delivered to %1$d of %2$d subscriber(s).', $done, $total) ?>
|
||
<?php if ($pending !== [] || $externals_pending) : ?>
|
||
<details class="mt-1">
|
||
<summary class="text-muted" style="cursor:pointer;">
|
||
<?= I18N::translate('Pending') ?>
|
||
</summary>
|
||
<ul class="list-unstyled small mb-0 mt-1 ps-2">
|
||
<?php foreach ($pending as $user) : ?>
|
||
<li>
|
||
<span class="text-warning">⏳</span>
|
||
<?= e($user->realName()) ?>
|
||
<span class="text-muted"><<?= e($user->email()) ?>></span>
|
||
</li>
|
||
<?php endforeach ?>
|
||
<?php if ($externals_pending) : ?>
|
||
<li>
|
||
<span class="text-warning">⏳</span>
|
||
<?= I18N::translate('External recipients (%d)', count($external_addresses)) ?>
|
||
</li>
|
||
<?php endif ?>
|
||
</ul>
|
||
</details>
|
||
<?php endif ?>
|
||
<?php endif ?>
|
||
</div>
|
||
<?php elseif ($val !== '' && $current_v === 0) : ?>
|
||
<div class="small text-muted mt-1">
|
||
<?= I18N::translate('Save to schedule delivery.') ?>
|
||
</div>
|
||
<?php endif ?>
|
||
</div>
|
||
<?php endforeach ?>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div class="row mb-3">
|
||
<label class="col-sm-3 col-form-label">
|
||
<?= I18N::translate('Subscribed users') ?>
|
||
</label>
|
||
<div class="col-sm-9">
|
||
<input type="hidden" name="users-submitted-<?= $id ?>" value="1">
|
||
<?php if ($all_users->isEmpty()) : ?>
|
||
<small class="form-text text-muted">
|
||
<?= I18N::translate('No users with email addresses found.') ?>
|
||
</small>
|
||
<?php else : ?>
|
||
<small class="form-text text-muted d-block mb-2">
|
||
<?= I18N::translate('Tick a user to subscribe them to this tree’s newsletter. Users can still adjust their own subscription on their account page.') ?>
|
||
</small>
|
||
<div class="border rounded p-2"
|
||
style="max-height: 18rem; overflow-y: auto;">
|
||
<?php foreach ($all_users as $user) : ?>
|
||
<?php
|
||
$field = 'subscribe-' . $id . '-' . $user->id();
|
||
$is_subbed = $tree->getUserPreference($user, Configuration::USER_PREF_SUBSCRIBED) === '1';
|
||
?>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox"
|
||
id="<?= e($field) ?>" name="<?= e($field) ?>"
|
||
value="1" <?= $is_subbed ? 'checked' : '' ?>>
|
||
<label class="form-check-label" for="<?= e($field) ?>">
|
||
<?= e($user->realName()) ?>
|
||
<small class="text-muted">
|
||
<<?= e($user->email()) ?>>
|
||
</small>
|
||
</label>
|
||
</div>
|
||
<?php endforeach ?>
|
||
</div>
|
||
<?php endif ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($last_sent > 0) : ?>
|
||
<div class="row mb-3">
|
||
<div class="offset-sm-3 col-sm-9">
|
||
<small class="text-muted">
|
||
<?= I18N::translate('Last sent: %s', date('Y-m-d H:i', $last_sent)) ?>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<?php endif ?>
|
||
</div>
|
||
</fieldset>
|
||
<?php endforeach ?>
|
||
|
||
<fieldset class="card mb-4">
|
||
<legend class="card-header h4"><?= I18N::translate('Cron token') ?></legend>
|
||
<div class="card-body">
|
||
<p>
|
||
<?= I18N::translate('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.') ?>
|
||
</p>
|
||
<pre class="bg-light p-2"><?= e($cron_url) ?></pre>
|
||
<div class="form-check mb-2">
|
||
<input class="form-check-input" type="checkbox" id="regenerate_token"
|
||
name="regenerate_token" value="1">
|
||
<label class="form-check-label" for="regenerate_token">
|
||
<?= I18N::translate('Regenerate token') ?>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<button type="submit" class="btn btn-primary">
|
||
<?= view('icons/save') ?>
|
||
<?= I18N::translate('Save') ?>
|
||
</button>
|
||
</form>
|
||
|
||
<form method="post" action="<?= e(route('module', ['module' => $module->name(), 'action' => 'SendNowAdmin'])) ?>" class="mt-3">
|
||
<?= csrf_field() ?>
|
||
<button type="submit" class="btn btn-secondary"
|
||
onclick="return confirm('<?= e(I18N::translate('Send the newsletter now for every enabled tree?')) ?>');">
|
||
<?= I18N::translate('Send now') ?>
|
||
</button>
|
||
</form>
|