Completing the PDF download and review recipients pages. Refactored.

This commit is contained in:
Adrian Stephens 2026-04-23 14:29:15 +01:00
parent 1835e75924
commit 63e2daaa9b
72 changed files with 6808 additions and 687 deletions

39
AGENTS.md Normal file
View File

@ -0,0 +1,39 @@
# Wordpress coding rules
- Escape every rendered variable.
- Never trust request data.
- Validate first, sanitize second.
- Use nonces + capability checks for state changes.
- Use WordPress APIs before custom code.
- Use `$wpdb->prepare()` for dynamic SQL.
- Prefix all identifiers.
- Wrap user-facing strings in i18n functions.
- Keep templates dumb.
- Prefer readable, hook-friendly code.
- Trap all possible errors for on-screen display. Do not allow Wordpress Cricital Errors to occur.
- Interact with third-party software via well-known interfaces, not custom DOM probing.
- Don't embed magic numbers in the code - such as known memory limits for a particular operation.
- All production configuration is via admin setup page (or user's profile page for per-user setup)
# Project Rules
Provide user error feedback on the pane/modal that was enabling user-interaction.
Prevent exiting the page with an error that causes loss of data already entered, instead
require that the user fix the error or "Quit" the page.
Obey rules in requirements/environment.md
# Generated JS/PHP Quoting Safety Rules
1. Never hand-escape JS strings inside PHP string literals when avoidable.
2. When emitting JS from PHP, pass dynamic string values via `json_encode(..., JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)` and assign them to JS variables.
3. Prefer PHP `nowdoc`/`heredoc` blocks for inline JS/HTML over long concatenated quoted strings.
4. Do not use JS regex literals inside PHP-quoted JS when a non-regex alternative exists (`split/join`, `replaceAll`, explicit loops).
5. If regex is required, construct with `new RegExp(...)` from safely encoded string parts, not literal `/.../` in PHP-quoted code.
6. Avoid nested quote composition (`"\'` or `\\"` chains). Build tokens with template variables + encoded parts.
7. For Twig token generation, standardize one helper function and reuse it everywhere; do not duplicate escaping logic.
8. After any change to embedded JS in PHP, run:
- `php -l <changed php file>`
- project-wide PHP lint for plugin files before finishing.
9. If a change touches emitted JS, include a runtime sanity check step: open affected admin page and confirm browser console has no syntax errors.
10. In fixes for syntax/runtime issues, prefer minimal reversible changes first, then refactor for cleanliness only after recovery.

BIN
dist/feca_mailshots_plugin-0.1.92.zip vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,49 @@
# Requirements Review - 2026-04-23
## Scope
- Reviewed files under `requirements/` against current implementation in `feca_mailshots_plugin/`.
## Findings
### 1) High: Packaging script requirement not implemented
- Requirement:
- `requirements/environment.md` states `scripts/` must contain a packaging script that creates an uploadable WordPress plugin package.
- Reference: `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/environment.md:62`
- Current implementation:
- `scripts/` currently contains:
- `cleanup_testing_artefacts.sh`
- `deploy_remote.sh`
- `mysql_tunnel.sh`
- `run_fixture_server.sh`
- `sql/`
- No packaging script is present.
- Gap:
- Requirement is unmet.
### 2) High: Data source REST capability split requirement not implemented
- Requirement:
- Data source API auth/capabilities require:
- create/update/delete => management capability (e.g. `manage_mailshots`)
- read/preview/validate => read capability (e.g. `read_mailshots`)
- References:
- `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/mailshot_data_source.md:427`
- `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/mailshot_data_source.md:428`
- Current implementation:
- `DataSourcesAdminPage` defines one capability only:
- `private const CAPABILITY = 'edit_pages';`
- `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:13`
- Both REST permission callbacks use the same capability:
- `restCanRead()` -> `currentUserCan(self::CAPABILITY)`
- `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:584`
- `restCanManage()` -> `currentUserCan(self::CAPABILITY)`
- `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:589`
- Gap:
- Requirement for separate read/manage capability model is unmet.
## Notes
- This review focused on direct, verifiable requirement-to-code mismatches.
- Files reviewed include:
- `requirements/environment.md`
- `requirements/mailshot_data_source.md`
- `requirements/third_party_software.md`
- `feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php`

View File

@ -3,7 +3,7 @@
* Plugin Name: FECA Mailshots
* Plugin URI: https://fenedge.co.uk/
* Description: FECA mailshots plugin.
* Version: 0.1.26
* Version: 0.1.93
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: FECA

View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
trait AdminRequestHelpers
{
private function requestMethod(): string
{
$method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET';
return strtoupper(trim($method));
}
private function requestString(string $name, string $default = ''): string
{
return (string) ($this->wp->requestParam($name, $default) ?? $default);
}
private function requestInt(string $name, int $default = 0): int
{
return (int) ($this->wp->requestParam($name, (string) $default) ?? (string) $default);
}
private function requestOptionalInt(string $name = 'id'): ?int
{
$raw = trim($this->requestString($name, ''));
return $raw === '' ? null : (int) $raw;
}
private function enforceCapabilityOrJson(string $capability): bool
{
if ($this->wp->currentUserCan($capability)) {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return false;
}
private function enforcePostOrJson(): bool
{
if ($this->requestMethod() === 'POST') {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405);
return false;
}
private function enforceNonceOrJson(string $nonceAction): bool
{
$nonce = trim($this->requestString('_wpnonce', ''));
if ($nonce !== '' && $this->wp->verifyNonce($nonce, $nonceAction)) {
return true;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403);
return false;
}
private function enforceMutationGuardOrJson(string $capability, string $nonceAction): bool
{
if ($this->mutationGuardValid($capability, $nonceAction)) {
return true;
}
if (!$this->wp->currentUserCan($capability)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return false;
}
if ($this->requestMethod() !== 'POST') {
$this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405);
return false;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403);
return false;
}
private function mutationGuardValid(string $capability, string $nonceAction): bool
{
if (!$this->wp->currentUserCan($capability)) {
return false;
}
if ($this->requestMethod() !== 'POST') {
return false;
}
$nonce = trim($this->requestString('_wpnonce', ''));
if ($nonce === '') {
return false;
}
return $this->wp->verifyNonce($nonce, $nonceAction);
}
private function hiddenNonceField(string $nonceAction): string
{
return '<input type="hidden" name="_wpnonce" value="'
. htmlspecialchars($this->wp->createNonce($nonceAction), ENT_QUOTES)
. '">';
}
private function redirectTo(string $url): void
{
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
}
private function redirectToAdminPage(string $page, ?int $editId = null): void
{
$url = $this->wp->adminUrl('admin.php?page=' . $page . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : ''));
$this->redirectTo($url);
}
private function uploadedFileToBase64(string $fileField): ?string
{
if (!isset($_FILES[$fileField]) || !is_array($_FILES[$fileField])) {
return null;
}
$error = (int) ($_FILES[$fileField]['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($error !== UPLOAD_ERR_OK) {
throw new \RuntimeException('File upload failed for "' . $fileField . '" with error code ' . $error . '.');
}
$tmp = (string) ($_FILES[$fileField]['tmp_name'] ?? '');
if ($tmp === '' || !is_file($tmp)) {
throw new \RuntimeException('Uploaded file for "' . $fileField . '" is not available.');
}
$bytes = file_get_contents($tmp);
if ($bytes === false) {
throw new \RuntimeException('Unable to read uploaded file for "' . $fileField . '".');
}
return base64_encode($bytes);
}
/** @return array<string,mixed>|null */
private function consumeOptionArray(string $optionKey): ?array
{
$raw = $this->wp->getOption($optionKey, null);
$this->wp->deleteOption($optionKey);
return is_array($raw) ? $raw : null;
}
/**
* Persists result banner payload and draft state in a consistent way:
* - always stores result payload
* - clears draft on success
* - stores draft on failure when provided
* @param array<string,mixed> $result
* @param array<string,mixed>|null $draft
*/
private function persistResultAndDraft(string $resultOptionKey, string $draftOptionKey, array $result, ?array $draft): void
{
$this->wp->updateOption($resultOptionKey, $result);
if (!empty($result['ok'])) {
$this->wp->deleteOption($draftOptionKey);
return;
}
if (is_array($draft)) {
$this->wp->updateOption($draftOptionKey, $draft);
}
}
/** @param array<string,mixed>|null $result */
private function modalErrorHtml(?array $result): string
{
if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) {
return '';
}
$html = '<div style="margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;">';
$html .= '<strong>Please fix the following:</strong><ul style="margin:8px 0 0 18px;">';
foreach ($result['errors'] as $error) {
$html .= '<li>' . htmlspecialchars((string) $error) . '</li>';
}
$html .= '</ul></div>';
return $html;
}
/**
* @param callable():array<string,mixed> $list
* @param callable():array<string,mixed> $save
* @param callable():array<string,mixed> $delete
*/
private function handleBasicCrudApi(string $op, callable $list, callable $save, callable $delete): void
{
if ($op === 'list') {
$this->wp->sendJson($list());
return;
}
if ($op === 'save') {
$this->wp->sendJson($save());
return;
}
if ($op === 'delete') {
$this->wp->sendJson($delete());
return;
}
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
}
/**
* @param list<string> $fieldIds
*/
private function modalEditorScript(
string $modalId,
string $openButtonId,
string $closeButtonId,
string $formId,
string $editorIdFieldId,
array $fieldIds,
bool $hasEdit,
bool $hasDraft,
bool $hasResult,
bool $resultOk,
?string $defaultSelectFieldId = null,
?string $defaultSelectValue = null,
?string $uploadFieldId = null,
?string $fileNameFieldId = null
): string {
$cfg = [
'modalId' => $modalId,
'openButtonId' => $openButtonId,
'closeButtonId' => $closeButtonId,
'formId' => $formId,
'editorIdFieldId' => $editorIdFieldId,
'fieldIds' => array_values($fieldIds),
'hasEdit' => $hasEdit,
'hasDraft' => $hasDraft,
'hasResult' => $hasResult,
'resultOk' => $resultOk,
'defaultSelectFieldId' => $defaultSelectFieldId,
'defaultSelectValue' => $defaultSelectValue,
'uploadFieldId' => $uploadFieldId,
'fileNameFieldId' => $fileNameFieldId,
];
return '<script>(function(){'
. 'var cfg=' . json_encode($cfg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';'
. 'var modal=document.getElementById(cfg.modalId);'
. 'var openBtn=document.getElementById(cfg.openButtonId);'
. 'var closeBtn=document.getElementById(cfg.closeButtonId);'
. 'var form=document.getElementById(cfg.formId);'
. 'var isDirty=false;'
. 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}'
. 'if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}'
. 'if(cfg.uploadFieldId&&cfg.fileNameFieldId){var upload=document.getElementById(cfg.uploadFieldId);var fileNameInput=document.getElementById(cfg.fileNameFieldId);if(upload&&fileNameInput){upload.addEventListener("change",function(){var file=(upload.files&&upload.files[0])?upload.files[0]:null;if(!file||String(file.name||"").trim()===""){return;}if(String(fileNameInput.value||"").trim()===""){fileNameInput.value=String(file.name||"");isDirty=true;}});}}'
. 'if(openBtn&&modal){openBtn.addEventListener("click",function(ev){if(ev&&typeof ev.preventDefault==="function"){ev.preventDefault();}var id=document.getElementById(cfg.editorIdFieldId);if(id){id.value="";}(cfg.fieldIds||[]).forEach(function(x){var el=document.getElementById(String(x||""));if(el){el.value="";}});if(cfg.defaultSelectFieldId){var j=document.getElementById(cfg.defaultSelectFieldId);if(j){j.value=String(cfg.defaultSelectValue||"");}}isDirty=false;modal.style.display="block";});}'
. 'if(closeBtn&&modal){closeBtn.addEventListener("click",function(ev){if(ev&&typeof ev.preventDefault==="function"){ev.preventDefault();}if(!confirmDiscard()){return;}modal.style.display="none";});}'
. 'if((cfg.hasEdit||cfg.hasDraft)&&modal&&(!cfg.hasResult||!cfg.resultOk)){modal.style.display="block";isDirty=false;}'
. '})();</script>';
}
}

View File

@ -9,8 +9,13 @@ use FecaMailshots\WordPress\WordPressFacade;
final class AttachmentsAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_attachments_ui_result';
private const DRAFT_OPTION_KEY = 'feca_mailshots_attachments_ui_draft';
private const PAGE_SLUG = 'feca-mailshots-attachments';
private const NONCE_ACTION = 'feca_mailshots_attachments';
/** @var callable(): AttachmentService */
private $serviceFactory;
private WordPressFacade $wp;
@ -32,7 +37,7 @@ final class AttachmentsAdminPage
public function registerMenu(): void
{
$this->wp->addSubmenuPage('feca-mailshot', 'Attachments', 'Attachments', self::CAPABILITY, 'feca-mailshots-attachments', [$this, 'render']);
$this->wp->addSubmenuPage('feca-mailshot', 'Attachments', 'Attachments', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']);
}
public function render(): void
@ -42,7 +47,11 @@ final class AttachmentsAdminPage
return;
}
$items = $this->service()->list();
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
$editId = (int) $draft['id'];
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['id'] ?? 0) === $editId) {
@ -50,11 +59,16 @@ final class AttachmentsAdminPage
break;
}
}
$result = $this->result();
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$name = (string) ($editItem['name'] ?? '');
$fileName = (string) ($editItem['file_name'] ?? '');
$mime = (string) ($editItem['mime_type'] ?? 'application/octet-stream');
$base64 = '';
if (is_array($draft)) {
$name = (string) ($draft['name'] ?? $name);
$fileName = (string) ($draft['file_name'] ?? $fileName);
$base64 = (string) ($draft['file_bytes_base64'] ?? '');
}
echo '<div class="wrap"><h1>Attachments</h1>';
if ($result !== null) {
@ -76,7 +90,9 @@ final class AttachmentsAdminPage
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="att-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="att-editor-id">';
} else {
@ -85,13 +101,12 @@ final class AttachmentsAdminPage
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="att_name">Name</label></th><td><input class="regular-text" type="text" id="att_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_file_name">File Name</label></th><td><input class="regular-text" type="text" id="att_file_name" name="file_name" value="' . htmlspecialchars($fileName, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_mime">MIME Type</label></th><td><input class="regular-text" type="text" id="att_mime" name="mime_type" value="' . htmlspecialchars($mime, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="att_file_upload">Upload File</label></th><td><input type="file" id="att_file_upload" name="file_upload"></td></tr>';
echo '<tr><th scope="row"><label for="att_base64">File Bytes (Base64)</label></th><td><textarea id="att_base64" name="file_bytes_base64" rows="4" class="large-text code"></textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '<tr><th scope="row"><label for="att_base64">File Bytes (Base64)</label></th><td><textarea id="att_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Attachment' : 'Create Attachment') . '</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-attachments')) . '">Cancel Edit</a>';
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>';
}
echo '</p></form>';
echo '</div></div>';
@ -110,6 +125,7 @@ final class AttachmentsAdminPage
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this attachment?\');">Delete</button>';
echo '</form></td>';
@ -119,54 +135,49 @@ final class AttachmentsAdminPage
echo '<tr><td colspan="6">No attachments found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");var form=document.getElementById("att-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
echo $this->modalEditorScript(
'att-editor-modal',
'att-open-new',
'att-close-editor',
'att-editor-form',
'att-editor-id',
['att_name', 'att_file_name', 'att_base64'],
$editId > 0,
is_array($draft),
$result !== null,
!empty($result['ok']),
null,
null,
'att_file_upload',
'att_file_name'
);
echo '</div>';
}
public function handleUiSave(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
$id = $idRaw === '' ? null : (int) $idRaw;
$base64 = (string) ($this->wp->requestParam('file_bytes_base64', '') ?? '');
if ($base64 === '' && isset($_FILES['file_upload']) && is_array($_FILES['file_upload']) && (int) ($_FILES['file_upload']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$tmp = (string) ($_FILES['file_upload']['tmp_name'] ?? '');
if ($tmp !== '' && is_file($tmp)) {
$bytes = file_get_contents($tmp);
if ($bytes !== false) {
$base64 = base64_encode($bytes);
}
}
}
$payload = [
'name' => (string) ($this->wp->requestParam('name', '') ?? ''),
'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''),
'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'),
'file_bytes_base64' => $base64,
];
$id = $this->requestOptionalInt('id');
$uploadedBase64 = $this->uploadedFileToBase64('file_upload');
$base64 = $uploadedBase64 !== null ? $uploadedBase64 : $this->requestString('file_bytes_base64', '');
$payload = $this->attachmentPayload($base64);
$result = $this->service()->save($id, $payload);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->persistResultAndDraft(self::RESULT_OPTION_KEY, self::DRAFT_OPTION_KEY, $result, ['id' => $id] + $payload);
$editId = $id;
if (!empty($result['ok']) && isset($result['id'])) {
$editId = (int) $result['id'];
}
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : ''));
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
$this->redirectToAdminPage(self::PAGE_SLUG, $editId);
}
public function handleUiDelete(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$id = $this->requestInt('id', 0);
$result = ['ok' => true];
try {
if ($id > 0) {
@ -176,48 +187,38 @@ final class AttachmentsAdminPage
$result = ['ok' => false, 'errors' => [$e->getMessage()]];
}
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
if (!headers_sent()) {
header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments'), true, 302);
exit;
}
$this->redirectToAdminPage(self::PAGE_SLUG);
}
public function handleApi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return;
}
$op = (string) ($this->wp->requestParam('op', 'list') ?? 'list');
$op = $this->requestString('op', 'list');
try {
if ($op === 'list') {
$this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]);
return;
$this->handleBasicCrudApi(
$op,
fn(): array => ['ok' => true, 'items' => $this->service()->list()],
function (): array {
if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) {
return ['ok' => false, 'error' => 'Mutation guard rejected request.'];
}
if ($op === 'save') {
$idRaw = $this->wp->requestParam('id', '');
$id = $idRaw === '' ? null : (int) $idRaw;
$payload = [
'name' => (string) ($this->wp->requestParam('name', '') ?? ''),
'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''),
'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'),
'file_bytes_base64' => (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''),
];
$this->wp->sendJson($this->service()->save($id, $payload));
return;
$id = $this->requestOptionalInt('id');
$payload = $this->attachmentPayload($this->requestString('file_bytes_base64', ''));
return $this->service()->save($id, $payload);
},
function (): array {
if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) {
return ['ok' => false, 'error' => 'Mutation guard rejected request.'];
}
if ($op === 'delete') {
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$id = $this->requestInt('id', 0);
$this->service()->delete($id);
$this->wp->sendJson(['ok' => true]);
return;
return ['ok' => true];
}
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
);
} catch (\Throwable $e) {
$this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500);
}
@ -228,11 +229,15 @@ final class AttachmentsAdminPage
return ($this->serviceFactory)();
}
/** @return array<string,mixed>|null */
private function result(): ?array
/** @return array{name:string,file_name:string,mime_type:string,file_bytes_base64:string} */
private function attachmentPayload(string $base64): array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null;
return [
'name' => $this->requestString('name', ''),
'file_name' => $this->requestString('file_name', ''),
'mime_type' => $this->requestString('mime_type', ''),
'file_bytes_base64' => $base64,
];
}
}

View File

@ -8,8 +8,12 @@ use FecaMailshots\WordPress\WordPressFacade;
final class DataSourcesAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_data_sources_ui_result';
private const DRAFT_OPTION_KEY = 'feca_mailshots_data_sources_ui_draft';
private const NONCE_ACTION = 'feca_mailshots_data_sources';
/** @var callable(): \FecaMailshots\Application\DataSourceService */
private $serviceFactory;
@ -72,7 +76,11 @@ final class DataSourcesAdminPage
}
$items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort);
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
$editId = (int) $draft['id'];
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['ID'] ?? 0) === $editId) {
@ -82,7 +90,11 @@ final class DataSourcesAdminPage
}
$name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : '';
$dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : '';
$result = $this->result();
if (is_array($draft)) {
$name = (string) ($draft['name'] ?? $name);
$dsl = (string) ($draft['dsl_text'] ?? $dsl);
}
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$sourceFieldsMap = $this->service()->sourceFields();
$schemaList = $this->service()->listSchemas();
@ -116,14 +128,22 @@ final class DataSourcesAdminPage
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="padding:10px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<input type="hidden" name="page" value="feca-mailshot-data-sources">';
echo '<label>Filter <input class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence"></label> ';
echo '<label>Sort <select name="sort">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
echo '<label for="ds_filter_q" style="white-space:nowrap;padding-left:4px;"><strong>Filter</strong></label>';
echo '<input id="ds_filter_q" class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence">';
echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">';
echo '<label for="ds_filter_sort" style="white-space:nowrap;padding-left:4px;"><strong>Sort</strong></label>';
echo '<select id="ds_filter_sort" name="sort">';
foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) {
$selected = $sort === $value ? ' selected' : '';
echo '<option value="' . htmlspecialchars($value, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> ';
echo '</select>';
echo '</div>';
echo '<button class="button" type="submit">Apply</button>';
echo '</div>';
echo '</form>';
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
@ -131,6 +151,8 @@ final class DataSourcesAdminPage
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
echo $this->modalErrorHtml($result);
echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="ds-editor-id">';
} else {
@ -167,11 +189,9 @@ final class DataSourcesAdminPage
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>';
echo '<strong>Add custom source</strong><br>';
$defaultSchema = in_array('fenedgec_members', $schemaList, true) ? 'fenedgec_members' : '';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
foreach ($schemaList as $schema) {
$selected = ((string) $schema === $defaultSchema) ? ' selected' : '';
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars((string) $schema) . '</option>';
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '">' . htmlspecialchars((string) $schema) . '</option>';
}
echo '</select></label> ';
echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> ';
@ -245,6 +265,7 @@ final class DataSourcesAdminPage
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this data source?\');">Delete</button>';
echo '</form></td>';
@ -296,8 +317,8 @@ final class DataSourcesAdminPage
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["primary-contact","Contact is primary"],["fen1-contact","Contact is FEN1"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];';
echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}';
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Unable to list tables for this schema.";} };fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("REST "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"REST error");}fill((j&&j.items)||[]);}).catch(function(){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr((e&&e.message?e.message+". ":"")+"Need MySQL grants on the selected schema.");});});}';
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();}}).catch(function(){});}return [];}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Table lookup failed. Resolve the error before continuing.";}if(err){err.textContent="Table lookup failed: "+(msg||"unknown error");}};var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema);fetch(u,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr(e&&e.message?e.message:"Need MySQL grants on the selected schema.");});}';
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);fetch(u,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();return;}if(err){err.textContent="Source field lookup returned no fields for "+source+".";}}).catch(function(e){if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}});}return [];}';
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.style.marginLeft="8px";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
@ -324,9 +345,10 @@ final class DataSourcesAdminPage
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
echo 'var hasDraft=' . (is_array($draft) ? 'true' : 'false') . ';';
echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
echo 'if(hasEdit&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
echo 'if((hasEdit||hasDraft)&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
echo '})();';
echo '</script>';
echo '</div>';
@ -334,28 +356,34 @@ final class DataSourcesAdminPage
public function handleUiSave(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
$id = $idRaw === '' ? null : (int) $idRaw;
$name = (string) ($this->wp->requestParam('name', '') ?? '');
$dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? '');
$result = $this->service()->save(
$id,
(string) ($this->wp->requestParam('name', '') ?? ''),
(string) ($this->wp->requestParam('dsl_text', '') ?? '')
$name,
$dsl
);
$this->persistResultAndDraft(
self::RESULT_OPTION_KEY,
self::DRAFT_OPTION_KEY,
$result,
['id' => $id, 'name' => $name, 'dsl_text' => $dsl]
);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirectToEditId($result, $id);
}
public function handleUiValidate(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? '');
$name = (string) ($this->wp->requestParam('name', '') ?? '');
$validation = $this->service()->validateDsl($dsl);
$result = [
'ok' => ($validation['errors'] ?? []) === [],
@ -365,6 +393,7 @@ final class DataSourcesAdminPage
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
$id = $idRaw === '' ? null : (int) $idRaw;
$this->wp->updateOption(self::DRAFT_OPTION_KEY, ['id' => $id, 'name' => $name, 'dsl_text' => $dsl]);
$this->redirectToEditId($result, $id);
}
@ -375,6 +404,7 @@ final class DataSourcesAdminPage
return;
}
$dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? '');
$name = (string) ($this->wp->requestParam('name', '') ?? '');
$preview = $this->service()->preview($dsl, 50);
$result = [
'ok' => ($preview['errors'] ?? []) === [],
@ -386,6 +416,7 @@ final class DataSourcesAdminPage
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
$id = $idRaw === '' ? null : (int) $idRaw;
$this->wp->updateOption(self::DRAFT_OPTION_KEY, ['id' => $id, 'name' => $name, 'dsl_text' => $dsl]);
$this->redirectToEditId($result, $id);
}
@ -464,6 +495,9 @@ final class DataSourcesAdminPage
return;
}
if ($op === 'save') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = $this->wp->requestParam('id', '');
$id = $idRaw === '' ? null : (int) $idRaw;
$name = (string) ($this->wp->requestParam('name', '') ?? '');
@ -472,6 +506,9 @@ final class DataSourcesAdminPage
return;
}
if ($op === 'delete') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$this->service()->delete($id);
$this->wp->sendJson(['ok' => true]);
@ -686,9 +723,9 @@ final class DataSourcesAdminPage
}
/** @param array<string,mixed> $result */
private function redirectToEditId(array $result, ?int $fallbackId): void
private function redirectToEditId(array $result, ?int $currentEditId): void
{
$editId = $fallbackId;
$editId = $currentEditId;
if (!empty($result['ok']) && isset($result['id'])) {
$editId = (int) $result['id'];
}
@ -699,14 +736,6 @@ final class DataSourcesAdminPage
}
}
/** @return array<string,mixed>|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null;
}
/**
* @param mixed $request
* @param mixed $default

View File

@ -0,0 +1,425 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\MailshotRunService;
use FecaMailshots\Application\MailshotService;
use FecaMailshots\WordPress\WordPressFacade;
final class DownloadPdfAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_download_pdf_ui_result';
private const PAGE_SLUG = 'feca-mailshots-download-pdf';
private const DOWNLOAD_MEMORY_LIMIT_ENV = 'FECA_MAILSHOTS_DOWNLOAD_MEMORY_LIMIT';
private const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
private const DEFAULT_DOWNLOAD_MEMORY_LIMIT = '512M';
private const NONCE_ACTION = 'feca_mailshots_download_pdf';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
/** @var callable(): MailshotService */
private $mailshotServiceFactory;
private WordPressFacade $wp;
/** @param callable(): MailshotRunService $runServiceFactory @param callable(): MailshotService $mailshotServiceFactory */
public function __construct(callable $runServiceFactory, callable $mailshotServiceFactory, WordPressFacade $wp)
{
$this->runServiceFactory = $runServiceFactory;
$this->mailshotServiceFactory = $mailshotServiceFactory;
$this->wp = $wp;
}
public function register(): void
{
$this->wp->addAction('admin_menu', [$this, 'registerMenu']);
$this->wp->addAction('admin_post_feca_mailshots_download_pdf_ui', [$this, 'handleDownloadUi']);
}
public function registerMenu(): void
{
$this->wp->addSubmenuPage('feca-mailshot', 'Download PDF', 'Download PDF', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']);
}
public function render(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
echo 'Permission denied';
return;
}
$mailshots = $this->mailshotService()->list();
$selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
if ($selectedMailshotId <= 0 && $mailshots !== []) {
$selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0);
}
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC';
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC';
echo '<div class="wrap"><h1>Download PDF</h1>';
echo '<p style="margin:4px 0 12px 0;color:#6b7280;font-size:12px;">Build stamp: ' . htmlspecialchars($buildStamp, ENT_QUOTES) . ' | Rendered: ' . htmlspecialchars($renderedAt, ENT_QUOTES) . '</p>';
echo '<p>Run the selected mailshot without sending any email, and download generated PDF attachments.</p>';
if ($result !== null) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} else {
echo '<p style="margin:6px 0 0 0;">Generated: ' . (int) ($result['generated_count'] ?? 0)
. ', Recipients: ' . (int) ($result['recipient_count'] ?? 0)
. ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '</p>';
}
echo '</div>';
}
echo '<form id="feca-download-pdf-form" method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;max-width:860px;">';
echo '<input type="hidden" name="action" value="feca_mailshots_download_pdf_ui">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" id="feca-download-format-shadow" name="download_format" value="">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
echo '<label for="dp_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
echo '<select id="dp_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $mailshot) {
$id = (int) ($mailshot['id'] ?? 0);
$purpose = trim((string) ($mailshot['Purpose'] ?? ''));
if ($purpose === '') {
$purpose = 'Mailshot #' . $id;
}
$selected = $id === $selectedMailshotId ? ' selected' : '';
echo '<option value="' . $id . '"' . $selected . '>' . htmlspecialchars($purpose) . '</option>';
}
echo '</select></div></div>';
echo '<p style="margin-top:12px;">';
echo '<button id="feca-download-merged-btn" class="button button-primary" type="submit" name="download_format" value="merged" onclick="return confirm(\'Generate and download a single merged PDF?\');">Download Merged PDF</button> ';
echo '<button id="feca-download-zip-btn" class="button" type="submit" name="download_format" value="zip" onclick="return confirm(\'Generate and download a ZIP of individual PDFs?\');">Download ZIP of PDFs</button>';
echo '</p>';
echo '<div id="feca-download-progress" style="display:none;margin-top:8px;padding:10px;border:1px solid #c8d7e1;background:#f6fbff;max-width:520px;">';
echo '<strong>Generating PDFs...</strong> Please wait. This can take a few seconds for larger mailshots.';
echo '</div>';
echo '</form>';
echo '<script>';
echo '(function(){';
echo 'var actualForm=document.getElementById("feca-download-pdf-form");';
echo 'if(!actualForm){return;}';
echo 'var merged=document.getElementById("feca-download-merged-btn");';
echo 'var zip=document.getElementById("feca-download-zip-btn");';
echo 'var progress=document.getElementById("feca-download-progress");';
echo 'var shadow=document.getElementById("feca-download-format-shadow");';
echo 'actualForm.addEventListener("submit",function(ev){';
echo 'var submitter=ev&&ev.submitter?ev.submitter:null;';
echo 'if(shadow&&submitter&&submitter.name==="download_format"){shadow.value=submitter.value||"";}';
echo 'if(submitter){submitter.disabled=true;submitter.classList.add("disabled");}';
echo 'if(progress){progress.style.display="block";}';
echo '});';
echo '})();';
echo '</script>';
echo '</div>';
}
public function handleDownloadUi(): void
{
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = $this->requestInt('mailshot_id', 0);
$format = $this->requestString('download_format', 'merged');
if (!in_array($format, ['merged', 'zip'], true)) {
$format = 'merged';
}
$this->downloadDebugLog('handleDownloadUi.start', [
'mailshot_id' => $mailshotId,
'format' => $format,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
register_shutdown_function(function () use ($mailshotId, $format): void {
$err = error_get_last();
if (!is_array($err)) {
return;
}
$type = (int) ($err['type'] ?? 0);
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
if (!in_array($type, $fatalTypes, true)) {
return;
}
$this->downloadDebugLog('handleDownloadUi.shutdown_fatal', [
'mailshot_id' => $mailshotId,
'format' => $format,
'error_type' => $type,
'error_message' => (string) ($err['message'] ?? ''),
'error_file' => (string) ($err['file'] ?? ''),
'error_line' => (int) ($err['line'] ?? 0),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
});
if ($format === 'zip') {
try {
$this->downloadDebugLog('handleDownloadUi.zip.before_generate', ['mailshot_id' => $mailshotId]);
$result = $this->runService()->generatePdfZipToTemp($mailshotId);
$this->downloadDebugLog('handleDownloadUi.zip.after_generate', [
'mailshot_id' => $mailshotId,
'ok' => !empty($result['ok']),
'generated_count' => (int) ($result['generated_count'] ?? 0),
'recipient_count' => (int) ($result['recipient_count'] ?? 0),
'zip_path' => (string) ($result['zip_path'] ?? ''),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.zip.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
if (empty($result['ok'])) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
$zipPath = (string) ($result['zip_path'] ?? '');
if ($zipPath === '' || !is_file($zipPath)) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: archive file not found.']]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
try {
$this->downloadDebugLog('handleDownloadUi.zip.before_send', ['mailshot_id' => $mailshotId, 'zip_path' => $zipPath]);
$this->sendFileDownload('mailshot_' . $mailshotId . '_pdfs.zip', 'application/zip', $zipPath);
} catch (\Throwable $e) {
@unlink($zipPath);
$this->downloadDebugLog('handleDownloadUi.zip.send_exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
}
return;
}
try {
$this->downloadDebugLog('handleDownloadUi.merged.before_generate', ['mailshot_id' => $mailshotId]);
$result = $this->runService()->generatePdfBatch($mailshotId, true, false);
$this->downloadDebugLog('handleDownloadUi.merged.after_generate', [
'mailshot_id' => $mailshotId,
'ok' => !empty($result['ok']),
'generated_count' => (int) ($result['generated_count'] ?? 0),
'recipient_count' => (int) ($result['recipient_count'] ?? 0),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
if (empty($result['ok'])) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
$bytes = (string) ($result['merged_pdf_bytes'] ?? '');
if ($bytes === '') {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF bytes are empty.']]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
try {
$this->downloadDebugLog('handleDownloadUi.merged.before_send', ['mailshot_id' => $mailshotId, 'bytes_len' => strlen($bytes)]);
$this->sendBinaryDownload('mailshot_' . $mailshotId . '_merged.pdf', 'application/pdf', $bytes);
} catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.merged.send_exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF download failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
}
private function sendBinaryDownload(string $filename, string $contentType, string $bytes): void
{
if (headers_sent()) {
throw new \RuntimeException('Cannot send download: headers already sent.');
}
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"');
header('Content-Length: ' . strlen($bytes));
header('X-Content-Type-Options: nosniff');
echo $bytes;
exit;
}
private function sendFileDownload(string $filename, string $contentType, string $path): void
{
if (headers_sent()) {
throw new \RuntimeException('Cannot send download: headers already sent.');
}
$size = @filesize($path);
if (!is_int($size) || $size <= 0) {
throw new \RuntimeException('Cannot send download: file is missing or empty.');
}
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $filename) . '"');
header('Content-Length: ' . (string) $size);
header('X-Content-Type-Options: nosniff');
$in = @fopen($path, 'rb');
if (!is_resource($in)) {
throw new \RuntimeException('Cannot read download file.');
}
while (!feof($in)) {
$chunk = fread($in, 8192);
if ($chunk === false) {
fclose($in);
throw new \RuntimeException('Failed while reading download file.');
}
echo $chunk;
}
fclose($in);
@unlink($path);
exit;
}
private function runService(): MailshotRunService
{
return ($this->runServiceFactory)();
}
private function mailshotService(): MailshotService
{
return ($this->mailshotServiceFactory)();
}
/** @param array<string,mixed> $context */
private function downloadDebugLog(string $event, array $context = []): void
{
$line = '[feca-mailshots/download-pdf][' . gmdate('Y-m-d H:i:s') . ' UTC] ' . $event;
if ($context !== []) {
$json = json_encode($context);
if (is_string($json) && $json !== '') {
$line .= ' ' . $json;
}
}
error_log($line);
if (!defined('ABSPATH')) {
return;
}
$path = ABSPATH . 'wp-content/uploads/feca_mailshots_download_debug.log';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (is_dir($dir) && is_writable($dir)) {
@error_log($line . PHP_EOL, 3, $path);
}
}
private function maybeRaiseMemoryLimit(string $target): void
{
if (!function_exists('ini_get') || !function_exists('ini_set')) {
return;
}
$current = (string) ini_get('memory_limit');
$currentBytes = $this->memoryLimitToBytes($current);
$targetBytes = $this->memoryLimitToBytes($target);
if ($currentBytes < 0 || $targetBytes <= 0) {
return;
}
if ($currentBytes >= $targetBytes) {
return;
}
$old = $current;
@ini_set('memory_limit', $target);
$new = (string) ini_get('memory_limit');
$this->downloadDebugLog('handleDownloadUi.memory_limit_adjust', [
'old' => $old,
'new' => $new,
'target' => $target,
]);
}
private function resolveDownloadMemoryLimitTarget(): string
{
$optionValue = $this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '');
if (is_string($optionValue)) {
$v = trim($optionValue);
if ($this->memoryLimitToBytes($v) > 0) {
return $v;
}
}
$envValue = getenv(self::DOWNLOAD_MEMORY_LIMIT_ENV);
if (is_string($envValue)) {
$v = trim($envValue);
if ($this->memoryLimitToBytes($v) > 0) {
return $v;
}
}
return self::DEFAULT_DOWNLOAD_MEMORY_LIMIT;
}
private function memoryLimitToBytes(string $limit): int
{
$v = trim($limit);
if ($v === '') {
return 0;
}
if ($v === '-1') {
return -1;
}
$unit = strtolower(substr($v, -1));
if (ctype_alpha($unit)) {
$num = (float) substr($v, 0, -1);
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
return (int) $v;
}
}

View File

@ -10,8 +10,11 @@ use FecaMailshots\WordPress\WordPressFacade;
final class MailshotTestAdminPage
{
use AdminRequestHelpers;
private const RESULT_OPTION_KEY = 'feca_mailshots_test_ui_result';
private const CAPABILITY = 'edit_pages';
private const NONCE_ACTION = 'feca_mailshots_test';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
@ -51,14 +54,12 @@ final class MailshotTestAdminPage
$mailshots = $this->mailshotService()->list();
$selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
if ($selectedMailshotId <= 0 && $mailshots !== []) {
$selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0);
}
$preview = ['ok' => false, 'rows' => [], 'errors' => ['Select a mailshot.']];
if ($selectedMailshotId > 0) {
$preview = $this->runService()->previewRecipients($selectedMailshotId, 100);
}
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
$selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? '';
@ -88,7 +89,10 @@ final class MailshotTestAdminPage
if (!empty($result['sent_at'])) {
echo '<p style="margin:6px 0 0 0;">Sent at: <code>' . htmlspecialchars((string) $result['sent_at']) . '</code></p>';
}
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '</pre></details>';
if (($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) {
echo '<p style="margin:8px 0 0 0;"><button type="button" class="button button-primary" id="ms-open-render-preview">Open Render Preview</button></p>';
}
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars($this->debugJson($result), ENT_QUOTES) . '</pre></details>';
echo '</div>';
}
@ -96,7 +100,10 @@ final class MailshotTestAdminPage
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">';
echo '<input type="hidden" name="page" value="feca-mailshots-test">';
echo '<label>Mailshot: <select name="mailshot_id">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
echo '<label for="mst_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
echo '<select id="mst_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0);
$sel = $id === $selectedMailshotId ? ' selected' : '';
@ -106,7 +113,7 @@ final class MailshotTestAdminPage
}
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> <button class="button" type="submit">Load recipients</button>';
echo '</select></div><button class="button" type="submit">Load recipients</button></div>';
echo '</form>';
echo '</div>';
@ -114,46 +121,106 @@ final class MailshotTestAdminPage
echo '<div style="padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin:12px 0;">' . htmlspecialchars(implode('; ', $preview['errors'])) . '</div>';
}
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">2. Render / Send Test</h2>';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<label>Recipient row: <select name="recipient_index">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:380px;">';
echo '<label for="mst_recipient_index" style="white-space:nowrap;padding-left:4px;"><strong>Recipient row</strong></label>';
echo '<select id="mst_recipient_index" name="recipient_index">';
foreach ($rows as $r) {
$idx = (int) ($r['index'] ?? 0);
$sel = $idx === $selectedRecipientIndex ? ' selected' : '';
$email = (string) ($r['recipient_email'] ?? '');
$key = (string) ($r['recipient_key'] ?? '');
$field = (string) ($r['recipient_key_field'] ?? '');
$text = sprintf('#%d | %s=%s | %s', $idx, $field, $key, $email !== '' ? $email : '(no email)');
$text = $email !== '' ? $email : '(no email)';
if ($key !== '' && strcasecmp($key, $email) !== 0) {
$text .= ' | ' . $key;
}
echo '<option value="' . $idx . '"' . $sel . '>' . htmlspecialchars($text) . '</option>';
}
echo '</select></label>';
echo '</select></div>';
echo '</div>';
echo '<p><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></p>';
echo '<label>Test email address: <input class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '"></label>';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:420px;">';
echo '<label for="mst_test_email" style="white-space:nowrap;padding-left:4px;"><strong>Test email address</strong></label>';
echo '<input id="mst_test_email" class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '">';
echo '</div>';
echo '</div>';
echo '<p><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" onclick="return confirm(\'Send one test email to the entered address?\');">Send Test Email</button></p>';
echo '</form>';
if ($rows !== []) {
$sampleColumns = $this->sampleColumns($rows, 4);
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">';
echo '<h2 style="margin-top:0;">Recipient Sample (First 20)</h2><table class="widefat striped"><thead><tr><th>#</th><th>Key</th><th>Email</th></tr></thead><tbody>';
echo '<h2 style="margin-top:0;">Recipient Sample (First 20)</h2><table class="widefat striped"><thead><tr><th>#</th><th>Key</th><th>Email</th>';
foreach ($sampleColumns as $col) {
echo '<th>' . htmlspecialchars($col) . '</th>';
}
echo '</tr></thead><tbody>';
foreach (array_slice($rows, 0, 20) as $r) {
echo '<tr><td>' . (int) ($r['index'] ?? 0) . '</td><td>' . htmlspecialchars((string) ($r['recipient_key'] ?? '')) . '</td><td>' . htmlspecialchars((string) ($r['recipient_email'] ?? '')) . '</td></tr>';
$rowData = is_array($r['row'] ?? null) ? $r['row'] : [];
echo '<tr><td>' . (int) ($r['index'] ?? 0) . '</td><td>' . htmlspecialchars((string) ($r['recipient_key'] ?? '')) . '</td><td>' . htmlspecialchars((string) ($r['recipient_email'] ?? '')) . '</td>';
foreach ($sampleColumns as $col) {
echo '<td>' . htmlspecialchars($this->displayCellValue($rowData[$col] ?? null)) . '</td>';
}
echo '</tr>';
}
echo '</tbody></table>';
echo '</div>';
}
if ($result !== null && ($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) {
$rendered = $result['rendered'];
$recipient = $this->selectedRecipientSummary($rows, $selectedRecipientIndex);
$recipientText = $recipient['email'] ?? '';
if (($recipient['key'] ?? '') !== '' && strcasecmp((string) ($recipient['key'] ?? ''), (string) ($recipient['email'] ?? '')) !== 0) {
$recipientText .= ($recipientText !== '' ? ' | ' : '') . (string) $recipient['key'];
}
if ($recipientText === '') {
$recipientText = '(recipient unavailable)';
}
$subject = (string) ($rendered['subject'] ?? '');
$messageHtml = (string) ($rendered['message'] ?? '');
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
echo '<div id="ms-render-preview-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:100000;">';
echo '<div style="max-width:1100px;margin:28px auto;background:#fff;padding:14px;max-height:90vh;overflow:auto;">';
echo '<h2 style="margin-top:0;">Render Preview</h2>';
echo '<p style="margin:0 0 8px 0;"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>';
echo '<p style="margin:0 0 10px 0;"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>';
echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">';
echo '<div><h3 style="margin:0 0 8px 0;">Message (HTML)</h3><iframe sandbox="" style="width:100%;height:320px;border:1px solid #dcdcde;background:#fff;" srcdoc="' . htmlspecialchars($messageHtml, ENT_QUOTES) . '"></iframe></div>';
echo '<div><h3 style="margin:0 0 8px 0;">PDF Attachment (HTML)</h3><iframe sandbox="" style="width:100%;height:320px;border:1px solid #dcdcde;background:#fff;" srcdoc="' . htmlspecialchars($pdfHtml, ENT_QUOTES) . '"></iframe></div>';
echo '</div>';
echo '<p style="margin-top:12px;"><button type="button" class="button button-primary" id="ms-close-render-preview">Close</button></p>';
echo '</div></div>';
echo '<script>(function(){';
echo 'var modal=document.getElementById("ms-render-preview-modal");';
echo 'var open=document.getElementById("ms-open-render-preview");';
echo 'var close=document.getElementById("ms-close-render-preview");';
echo 'if(!modal){return;}';
echo 'var show=function(){modal.style.display="block";};';
echo 'var hide=function(){modal.style.display="none";};';
echo 'if(open){open.addEventListener("click",show);}';
echo 'if(close){close.addEventListener("click",hide);}';
echo 'modal.addEventListener("click",function(ev){if(ev.target===modal){hide();}});';
echo 'document.addEventListener("keydown",function(ev){if(ev.key==="Escape"&&modal.style.display==="block"){hide();}});';
echo 'show();';
echo '})();</script>';
}
echo '</div>';
}
public function handleApi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return;
}
@ -167,6 +234,9 @@ final class MailshotTestAdminPage
}
if ($op === 'render_test') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$this->wp->sendJson($service->renderTest($mailshotId, $idx));
@ -174,6 +244,9 @@ final class MailshotTestAdminPage
}
if ($op === 'send_test') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$to = (string) ($this->wp->requestParam('test_email', '') ?? '');
@ -189,27 +262,36 @@ final class MailshotTestAdminPage
public function handleRenderUi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
try {
$result = $this->runService()->renderTest($mailshotId, $idx);
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Render test failed: ' . $e->getMessage()]];
}
$result['ui_action'] = 'render';
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''));
}
public function handleSendUi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$email = (string) ($this->wp->requestParam('test_email', '') ?? '');
try {
$result = $this->runService()->sendTest($mailshotId, $idx, $email);
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
}
unset($result['rendered']);
$result['ui_action'] = 'send';
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId, $idx, $email);
}
@ -240,4 +322,95 @@ final class MailshotTestAdminPage
{
return ($this->mailshotServiceFactory)();
}
/**
* @param list<array<string,mixed>> $rows
* @return list<string>
*/
private function sampleColumns(array $rows, int $maxColumns): array
{
if ($rows === [] || $maxColumns <= 0) {
return [];
}
$firstRow = is_array($rows[0]['row'] ?? null) ? $rows[0]['row'] : [];
if ($firstRow === []) {
return [];
}
$selected = [];
foreach (array_keys($firstRow) as $key) {
$name = (string) $key;
if ($name === '') {
continue;
}
if ($this->isIdLikeField($name) || $this->isEmailLikeField($name)) {
continue;
}
$selected[] = $name;
if (count($selected) >= $maxColumns) {
break;
}
}
return $selected;
}
private function isIdLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'id'
|| str_ends_with($name, '.id')
|| str_ends_with($name, '_id')
|| str_contains($name, 'accountid');
}
private function isEmailLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'email' || str_ends_with($name, '.email') || str_contains($name, 'email');
}
/** @param mixed $value */
private function displayCellValue($value): string
{
if ($value === null) {
return '';
}
if (is_scalar($value)) {
return trim((string) $value);
}
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
}
/**
* @param list<array<string,mixed>> $rows
* @return array{index:int,key:string,email:string}|array{}
*/
private function selectedRecipientSummary(array $rows, int $recipientIndex): array
{
foreach ($rows as $row) {
$idx = (int) ($row['index'] ?? -1);
if ($idx !== $recipientIndex) {
continue;
}
return [
'index' => $idx,
'key' => trim((string) ($row['recipient_key'] ?? '')),
'email' => trim((string) ($row['recipient_email'] ?? '')),
];
}
return [];
}
/** @param array<string,mixed> $payload */
private function debugJson(array $payload): string
{
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
if (is_string($json)) {
return $json;
}
$err = function_exists('json_last_error_msg') ? json_last_error_msg() : 'unknown error';
return '{"debug_error":"Failed to encode technical details: ' . $err . '"}';
}
}

View File

@ -9,8 +9,12 @@ use FecaMailshots\WordPress\WordPressFacade;
final class MailshotsAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_mailshots_ui_result';
private const DRAFT_OPTION_KEY = 'feca_mailshots_mailshots_ui_draft';
private const NONCE_ACTION = 'feca_mailshots_mailshots';
/** @var callable(): MailshotService */
private $serviceFactory;
@ -49,7 +53,12 @@ final class MailshotsAdminPage
$attachmentNames = $this->service()->attachmentNames();
sort($attachmentNames, SORT_NATURAL | SORT_FLAG_CASE);
$draftRaw = $this->wp->getOption(self::DRAFT_OPTION_KEY, null);
$draft = is_array($draftRaw) ? $draftRaw : null;
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
$editId = (int) $draft['id'];
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['id'] ?? 0) === $editId) {
@ -58,15 +67,6 @@ final class MailshotsAdminPage
}
}
$dataSource = (string) ($editItem['DataSource'] ?? '');
$tokenData = $dataSource !== '' ? $this->service()->tokenInsertionData($dataSource) : ['tokens' => [], 'errors' => []];
$pdfFieldOptions = [];
foreach (($tokenData['tokens'] ?? []) as $t) {
if (is_array($t) && isset($t['field'])) {
$pdfFieldOptions[] = (string) $t['field'];
}
}
$selectedAttachments = [];
$decoded = json_decode((string) ($editItem['AttachmentNames'] ?? '[]'), true);
if (is_array($decoded)) {
@ -81,7 +81,7 @@ final class MailshotsAdminPage
$form = [
'Purpose' => (string) ($editItem['Purpose'] ?? ''),
'DataSource' => $dataSource,
'DataSource' => (string) ($editItem['DataSource'] ?? ''),
'CC' => (string) ($editItem['CC'] ?? ''),
'BCC' => (string) ($editItem['BCC'] ?? ''),
'Subject' => (string) ($editItem['Subject'] ?? ''),
@ -91,12 +91,38 @@ final class MailshotsAdminPage
'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''),
'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''),
];
if (is_array($draft) && isset($draft['form']) && is_array($draft['form'])) {
foreach (['Purpose', 'DataSource', 'CC', 'BCC', 'Subject', 'Message', 'PDFAttachment', 'PDFFilenameDerivedFrom', 'RecipientEmailField', 'ReplyTo'] as $key) {
if (array_key_exists($key, $draft['form'])) {
$form[$key] = (string) $draft['form'][$key];
}
}
$csv = trim((string) ($draft['form']['AttachmentNamesCsv'] ?? ''));
if ($csv !== '') {
$selectedAttachments = array_values(array_unique(array_filter(array_map('trim', explode(',', $csv)), static fn(string $v): bool => $v !== '')));
} else {
$selectedAttachments = [];
}
}
$result = $this->result();
$dataSource = (string) ($form['DataSource'] ?? '');
$tokenData = $dataSource !== '' ? $this->service()->tokenInsertionData($dataSource) : ['tokens' => [], 'errors' => []];
$pdfFieldOptions = [];
foreach (($tokenData['tokens'] ?? []) as $t) {
if (is_array($t) && isset($t['field'])) {
$pdfFieldOptions[] = (string) $t['field'];
}
}
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC';
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC';
echo '<div class="wrap"><h1>Mailshots</h1>';
if ($result !== null) {
echo '<p style="margin:4px 0 12px 0;color:#6b7280;font-size:12px;">Build stamp: ' . htmlspecialchars($buildStamp, ENT_QUOTES) . ' | Rendered: ' . htmlspecialchars($renderedAt, ENT_QUOTES) . '</p>';
$isSaveError = (($result['context'] ?? '') === 'mailshot_save') && empty($result['ok']);
if ($result !== null && !$isSaveError && (!is_array($draft) || !empty($result['ok']))) {
$ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
@ -113,7 +139,9 @@ final class MailshotsAdminPage
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="ms-editor-id">';
} else {
@ -146,7 +174,9 @@ final class MailshotsAdminPage
$sel = $f === $form['RecipientEmailField'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
}
echo '</select><p class="description">Explicit field from data source row used as recipient email for run/send.</p></td></tr>';
echo '</select><p class="description">Optional. Set this for run/send workflows; leave blank for PDF-only workflows.</p>';
echo '<div id="ms-recipient-warning" style="display:none;margin-top:8px;padding:8px;border-left:4px solid #dba617;background:#fff8e1;">Mailshot does not have a specified RecipientEmailField, it cannot be used to send a mailshot.</div>';
echo '</td></tr>';
echo '<tr><th scope="row">Attachments</th><td colspan="3">';
echo '<select id="ms_attachment_pick"><option value="">Select attachment</option>';
@ -176,20 +206,23 @@ final class MailshotsAdminPage
echo '</form>';
echo '<div id="ms-template-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<h3 id="ms-template-title" style="margin-top:0;">Template Editor</h3>';
echo '<p><button type="button" class="button button-small" id="ms-template-mode-visual">Visual</button> <button type="button" class="button button-small" id="ms-template-mode-code">Code</button></p>';
echo '<div id="ms-template-panel" style="max-width:980px;margin:30px auto;background:#fff;padding:12px;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;">';
echo '<h3 id="ms-template-title" style="margin:0 0 8px 0;flex:0 0 auto;">Template Editor</h3>';
echo '<div id="ms-template-error" style="display:none;margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;"></div>';
echo '<div id="ms-template-token-controls" style="margin-bottom:10px;"></div>';
echo '<div id="ms-template-pdf-assets" style="display:none;margin-bottom:10px;"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>';
if (function_exists('wp_enqueue_editor')) {
wp_enqueue_editor();
}
echo '<textarea id="ms-template-html" rows="14" class="large-text code"></textarea>';
echo '<textarea id="ms-template-code" rows="14" class="large-text code" style="display:none;font-family:monospace;"></textarea>';
echo '<p class="description" style="margin-top:6px;">Use HTML for rich editing and Plain Text to view/edit exact HTML source (same underlying content).</p>';
echo '<p><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>';
echo '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.css">';
echo '<script src="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.js"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/ace.js"></script>';
echo '<div id="ms-template-editor-wrap" style="flex:1 1 auto;min-height:320px;height:60vh;max-height:60vh;overflow:auto;">';
echo '<textarea id="ms-template-jodit" rows="14" class="large-text code" style="height:100%;"></textarea>';
echo '</div>';
echo '<p style="margin:10px 0 0 0;flex:0 0 auto;"><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>';
echo '</div></div>';
echo '</div></div>';
echo '<style>
#ms-template-panel .jodit-container { height: 100% !important; }
</style>';
echo '<h2>Existing Mailshots</h2>';
echo '<table class="widefat striped"><thead><tr><th>Purpose</th><th>Data Source</th><th>Subject</th><th>Actions</th></tr></thead><tbody>';
@ -203,6 +236,7 @@ final class MailshotsAdminPage
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this mailshot?\');">Delete</button>';
echo '</form></td>';
@ -226,8 +260,24 @@ final class MailshotsAdminPage
var currentTokens = ' . json_encode(array_values(array_filter((array) ($tokenData['tokens'] ?? []), static fn($t): bool => is_array($t))), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';
var lastTarget = null;
var isDirty = false;
var markDirty = function () { isDirty = true; };
var cleanState = "";
var snapshotEditorState = function () {
var parts = [];
editor.querySelectorAll("input,select,textarea").forEach(function (el) {
var key = el.id || el.name || "";
parts.push(key + "=" + String(el.value || ""));
});
return parts.join("\n");
};
var setCleanState = function () {
cleanState = snapshotEditorState();
isDirty = false;
};
var markDirty = function () {
isDirty = snapshotEditorState() !== cleanState;
};
var confirmDiscard = function () {
markDirty();
if (!isDirty) { return true; }
return window.confirm("You have unsaved changes. Close without saving?");
};
@ -245,10 +295,8 @@ final class MailshotsAdminPage
var templateTitle = document.getElementById("ms-template-title");
var templateClose = document.getElementById("ms-template-close");
var templateSave = document.getElementById("ms-template-save");
var templateHtml = document.getElementById("ms-template-html");
var templateCode = document.getElementById("ms-template-code");
var templateModeVisualBtn = document.getElementById("ms-template-mode-visual");
var templateModeCodeBtn = document.getElementById("ms-template-mode-code");
var templateJodit = document.getElementById("ms-template-jodit");
var templateError = document.getElementById("ms-template-error");
var templateTokenControls = document.getElementById("ms-template-token-controls");
var templatePdfAssets = document.getElementById("ms-template-pdf-assets");
var pdfAssetPick = document.getElementById("ms_pdf_asset_pick");
@ -259,13 +307,21 @@ final class MailshotsAdminPage
var messageMeta = document.getElementById("ms-message-meta");
var pdfSnippet = document.getElementById("ms-pdf-snippet");
var pdfMeta = document.getElementById("ms-pdf-meta");
var recipientWarning = document.getElementById("ms-recipient-warning");
var activeTemplateField = null;
var templateDirty = false;
var templateRichEditor = null;
var templateCodeCanonicalRaw = "";
var templateCodeEdited = false;
var templateMode = "visual";
var templateJoditInstance = null;
var clearTemplateError = function () {
if (!templateError) { return; }
templateError.style.display = "none";
templateError.textContent = "";
};
var setTemplateError = function (message) {
if (!templateError) { return; }
templateError.style.display = "block";
templateError.textContent = String(message || "Template editor error.");
};
var cleanTokenLabel = function (value) {
var text = String(value || "");
@ -281,23 +337,39 @@ final class MailshotsAdminPage
s = s.replace(/\\s+/g, " ").trim();
return s;
};
var htmlEditorWrap = function () {
return document.getElementById("wp-ms-template-html-wrap");
};
var getTemplateHtmlValue = function () {
if (templateRichEditor) {
return String(templateRichEditor.getContent({ format: "raw" }) || "");
if (!templateJoditInstance) {
throw new Error("Template editor is not initialized.");
}
return String((templateHtml && templateHtml.value) || "");
if (typeof templateJoditInstance.synchronizeValues !== "function") {
throw new Error("Template editor cannot synchronize values.");
}
templateJoditInstance.synchronizeValues();
if (typeof templateJoditInstance.value !== "undefined") {
return String(templateJoditInstance.value || "");
}
if (typeof templateJoditInstance.getEditorValue === "function") {
return String(templateJoditInstance.getEditorValue() || "");
}
throw new Error("Template editor value API is unavailable.");
};
var setTemplateHtmlValue = function (value) {
var htmlValue = String(value || "");
if (templateRichEditor) {
templateRichEditor.setContent(htmlValue);
templateRichEditor.save();
if (!templateJoditInstance) {
if (templateJodit) { templateJodit.value = htmlValue; }
return;
}
if (templateHtml) {
templateHtml.value = htmlValue;
if (typeof templateJoditInstance.value !== "undefined") {
templateJoditInstance.value = htmlValue;
} else if (typeof templateJoditInstance.setEditorValue === "function") {
templateJoditInstance.setEditorValue(htmlValue);
}
if (templateJodit) { templateJodit.value = htmlValue; }
try {
if (typeof templateJoditInstance.synchronizeValues === "function") {
templateJoditInstance.synchronizeValues();
}
} catch (e) {
}
};
var scrubEditorArtifacts = function (value) {
@ -307,120 +379,41 @@ final class MailshotsAdminPage
htmlValue = htmlValue.replace(/\uFEFF/g, "");
return htmlValue;
};
var prettyPrintHtml = function (value) {
var src = String(value || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
var tokens = src.split(/(<[^>]+>)/g).filter(function (t) { return t !== ""; });
var out = [];
var indent = 0;
var rawBlock = "";
var pad = function (n) { return " ".repeat(Math.max(0, n)); };
var voidTag = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
for (var i = 0; i < tokens.length; i++) {
var token = String(tokens[i] || "");
var trimmed = token.trim();
if (!trimmed) { continue; }
if (rawBlock && trimmed.toLowerCase().indexOf("</" + rawBlock) === 0) {
indent = Math.max(0, indent - 1);
out.push(pad(indent) + trimmed);
rawBlock = "";
continue;
var joditContainer = function () {
if (!templateJodit) { return null; }
var el = templateJodit.nextElementSibling;
if (el && String(el.className || "").indexOf("jodit-container") >= 0) {
return el;
}
if (rawBlock) {
var rawLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (var rl = 0; rl < rawLines.length; rl++) {
var line = rawLines[rl];
if (line.trim() === "") {
out.push("");
} else {
out.push(pad(indent) + line.trim());
}
}
continue;
}
if (trimmed.indexOf("</") === 0) {
indent = Math.max(0, indent - 1);
}
if (trimmed.charAt(0) === "<") {
out.push(pad(indent) + trimmed);
var openTag = /^<([a-zA-Z0-9:-]+)/.exec(trimmed);
var closeSelf = /\/>\s*$/.test(trimmed);
var isClose = /^<\//.test(trimmed);
var isBang = /^<!/.test(trimmed) || /^<\?/.test(trimmed);
if (!isClose && !isBang && openTag && !closeSelf && !voidTag.test(openTag[1])) {
indent++;
var tagLower = String(openTag[1] || "").toLowerCase();
if (tagLower === "style" || tagLower === "script") {
rawBlock = tagLower;
}
}
} else {
var textLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (var tl = 0; tl < textLines.length; tl++) {
var tline = textLines[tl];
if (tline.trim() === "") {
out.push("");
} else {
out.push(pad(indent) + tline.trim());
}
}
}
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
return null;
};
var syncTemplateTextareaFromEditor = function () {
if (templateRichEditor) {
templateRichEditor.save();
}
if (!templateHtml) { return; }
var cleaned = scrubEditorArtifacts(templateHtml.value || "");
if (cleaned !== templateHtml.value) {
templateHtml.value = cleaned;
if (templateRichEditor) {
templateRichEditor.setContent(cleaned);
templateRichEditor.save();
}
var ensureJoditEditor = function () {
if (!templateJodit || templateJoditInstance) { return !!templateJoditInstance; }
if (!(window.Jodit && typeof window.Jodit.make === "function")) { return false; }
templateJoditInstance = window.Jodit.make("#ms-template-jodit", {
// Keep authored HTML/CSS isolated so template <style> does not leak into admin UI.
iframe: true,
defaultMode: window.Jodit.MODE_SOURCE,
height: "100%",
minHeight: 320,
allowResizeY: false,
useSplitMode: false,
sourceEditor: "ace",
toolbarAdaptive: false,
toolbarSticky: false,
buttons: ["bold", "italic", "ul", "ol", "|", "source"]
});
if (templateJoditInstance && templateJoditInstance.events && typeof templateJoditInstance.events.on === "function") {
templateJoditInstance.events.on("change", function () { templateDirty = true; });
}
return !!templateJoditInstance;
};
var setTemplateMode = function (mode) {
templateMode = (mode === "code") ? "code" : "visual";
var wrap = htmlEditorWrap();
if (templateMode === "code") {
syncTemplateTextareaFromEditor();
templateCodeCanonicalRaw = scrubEditorArtifacts(String((templateHtml && templateHtml.value) || ""));
templateCodeEdited = false;
if (templateCode) {
templateCode.value = prettyPrintHtml(templateCodeCanonicalRaw);
templateCode.style.display = "block";
}
if (wrap) {
wrap.style.display = "none";
var tabs = wrap.querySelector(".wp-editor-tabs");
if (tabs) { tabs.style.display = "none"; }
} else if (templateHtml) {
templateHtml.style.display = "none";
}
} else {
var codeValue = templateCode ? String(templateCode.value || "") : "";
if (templateCode && templateCodeEdited) {
var cleaned = scrubEditorArtifacts(codeValue);
if (templateRichEditor) {
templateRichEditor.setContent(cleaned);
templateRichEditor.save();
}
if (templateHtml) {
templateHtml.value = cleaned;
}
}
if (templateCode) {
templateCode.style.display = "none";
}
if (wrap) {
wrap.style.display = "block";
var tabs2 = wrap.querySelector(".wp-editor-tabs");
if (tabs2) { tabs2.style.display = "none"; }
} else if (templateHtml) {
templateHtml.style.display = "block";
}
var showJodit = function () {
if (templateJodit) { templateJodit.style.display = "none"; }
var jc = joditContainer();
if (jc) {
jc.style.display = "block";
jc.style.height = "100%";
}
};
var tokenFieldOptions = function (tokens) {
@ -466,15 +459,14 @@ final class MailshotsAdminPage
var insertToken = function (token) {
if (!token) { return; }
if (templateModal && templateModal.style.display === "block") {
if (templateMode === "code" && templateCode) {
insertIntoField(templateCode, token);
templateCodeEdited = true;
} else if (templateRichEditor) {
templateRichEditor.insertContent(token);
templateRichEditor.save();
templateDirty = true;
} else if (templateHtml) {
insertIntoField(templateHtml, token);
if (templateJoditInstance) {
if (templateJoditInstance.s && typeof templateJoditInstance.s.insertHTML === "function") {
templateJoditInstance.s.insertHTML(token);
} else {
templateJoditInstance.value = String(templateJoditInstance.value || "") + token;
}
} else if (templateJodit) {
insertIntoField(templateJodit, token);
}
templateDirty = true;
return;
@ -534,11 +526,17 @@ final class MailshotsAdminPage
if (tokenHelp) {
tokenHelp.textContent = fields.length ? "Insert tokens into Subject, Message or PDF Attachment editors." : "Select a data source to view tokens.";
}
updateRecipientWarning();
};
var updateRecipientWarning = function () {
if (!recipientWarning || !emailSelect) { return; }
recipientWarning.style.display = (String(emailSelect.value || "").trim() === "") ? "block" : "none";
};
var fetchTokensForDataSource = function (name) {
if (!name) {
currentTokens = [];
refreshTokenUi();
clearTemplateError();
return;
}
fetch(adminApiBase + "&op=tokens&data_source=" + encodeURIComponent(name), { credentials: "same-origin" })
@ -546,11 +544,11 @@ final class MailshotsAdminPage
.then(function (j) {
currentTokens = (j && j.tokens && Array.isArray(j.tokens)) ? j.tokens : [];
refreshTokenUi();
clearTemplateError();
})
.catch(function () {
currentTokens = [];
refreshTokenUi();
if (tokenHelp) { tokenHelp.textContent = "Unable to load tokens for selected data source."; }
.catch(function (e) {
if (tokenHelp) { tokenHelp.textContent = "Token lookup failed. Resolve this error before continuing."; }
setTemplateError("Token lookup failed for selected data source: " + (e && e.message ? e.message : "unknown error"));
});
};
var updateTemplateMeta = function () {
@ -565,39 +563,11 @@ final class MailshotsAdminPage
pdfSnippet.textContent = pdf ? pdf.substring(0, 160) + (pdf.length > 160 ? "..." : "") : "No PDF template content yet.";
}
};
var ensureTemplateRichEditor = function () {
if (!templateHtml || templateRichEditor) { return; }
if (!(window.wp && wp.editor && typeof wp.editor.initialize === "function")) { return; }
wp.editor.initialize("ms-template-html", {
tinymce: {
wpautop: false,
menubar: false,
toolbar1: "bold italic bullist numlist | link unlink | undo redo",
forced_root_block: false,
verify_html: false,
valid_elements: "*[*]",
extended_valid_elements: "style[type|media]",
entity_encoding: "raw",
convert_urls: false
},
quicktags: true,
mediaButtons: false
});
if (window.tinymce && typeof window.tinymce.get === "function") {
templateRichEditor = window.tinymce.get("ms-template-html");
}
if (templateRichEditor) {
templateRichEditor.on("change input keyup setcontent", function () {
templateRichEditor.save();
templateDirty = true;
});
templateRichEditor.on("focus", function () { lastTarget = templateHtml; });
}
};
var normalizeAssetName = function (name) {
var n = String(name || "").toLowerCase();
n = n.replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
return n;
var escapeTwigDoubleQuotedString = function (value) {
var s = String(value || "");
s = s.split(String.fromCharCode(92)).join(String.fromCharCode(92, 92));
s = s.split(String.fromCharCode(34)).join(String.fromCharCode(92, 34));
return s;
};
var loadPdfAssets = function () {
if (!pdfAssetPick) { return; }
@ -615,21 +585,24 @@ final class MailshotsAdminPage
o.textContent = name;
pdfAssetPick.appendChild(o);
});
clearTemplateError();
})
.catch(function () {
pdfAssetPick.innerHTML = "<option value=\\"\\">Unable to load assets</option>";
.catch(function (e) {
pdfAssetPick.innerHTML = "<option value=\\"\\">Select PDF asset</option>";
setTemplateError("PDF asset lookup failed: " + (e && e.message ? e.message : "unknown error"));
});
};
var openTemplateEditor = function (field, title, isPdf) {
if (!field || !templateModal) { return; }
ensureTemplateRichEditor();
activeTemplateField = field;
templateTitle.textContent = title;
setTemplateHtmlValue(field.value || "");
templateCodeCanonicalRaw = scrubEditorArtifacts(String(field.value || ""));
templateCodeEdited = false;
templateDirty = false;
setTemplateMode("visual");
clearTemplateError();
if (!ensureJoditEditor() || !templateJoditInstance) {
throw new Error("Jodit editor is required for template editing but failed to load.");
}
showJodit();
setTemplateHtmlValue(field.value || "");
templatePdfAssets.style.display = isPdf ? "block" : "none";
if (isPdf) { loadPdfAssets(); }
templateModal.style.display = "block";
@ -644,7 +617,7 @@ final class MailshotsAdminPage
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
editor.addEventListener("submit", function () { isDirty = false; });
editor.addEventListener("submit", function () { setCleanState(); });
if (subjectField) {
subjectField.addEventListener("focus", function () { lastTarget = subjectField; });
@ -655,61 +628,64 @@ final class MailshotsAdminPage
markDirty();
});
}
if (emailSelect) {
emailSelect.addEventListener("change", function () {
updateRecipientWarning();
markDirty();
});
}
if (openMessageEditor && messageField) {
openMessageEditor.addEventListener("click", function () {
try {
openTemplateEditor(messageField, "Edit Message", false);
} catch (e) {
setTemplateError(e && e.message ? e.message : "Unable to open message editor.");
}
});
}
if (openPdfEditor && pdfField) {
openPdfEditor.addEventListener("click", function () {
try {
openTemplateEditor(pdfField, "Edit PDF Attachment", true);
} catch (e) {
setTemplateError(e && e.message ? e.message : "Unable to open PDF attachment editor.");
}
});
}
if (templateClose) { templateClose.addEventListener("click", closeTemplateEditor); }
if (templateHtml) {
templateHtml.addEventListener("input", function () {
templateDirty = true;
});
templateHtml.addEventListener("focus", function () { lastTarget = templateHtml; });
}
if (templateCode) {
templateCode.addEventListener("input", function () {
templateDirty = true;
templateCodeEdited = true;
});
templateCode.addEventListener("focus", function () { lastTarget = templateCode; });
}
if (templateModeVisualBtn) { templateModeVisualBtn.addEventListener("click", function () { setTemplateMode("visual"); }); }
if (templateModeCodeBtn) { templateModeCodeBtn.addEventListener("click", function () { setTemplateMode("code"); }); }
if (templateSave) {
templateSave.addEventListener("click", function () {
var saveTemplateNow = function () {
if (!activeTemplateField) { return; }
var valueToSave = "";
if (templateMode === "code" && templateCode) {
valueToSave = scrubEditorArtifacts(String(templateCode.value || ""));
if (templateHtml) { templateHtml.value = valueToSave; }
if (templateRichEditor) {
templateRichEditor.setContent(valueToSave);
templateRichEditor.save();
}
} else {
syncTemplateTextareaFromEditor();
valueToSave = scrubEditorArtifacts(getTemplateHtmlValue());
if (templateHtml) { templateHtml.value = valueToSave; }
}
activeTemplateField.value = valueToSave;
var rawValue = getTemplateHtmlValue();
activeTemplateField.value = scrubEditorArtifacts(rawValue);
templateDirty = false;
clearTemplateError();
templateModal.style.display = "none";
updateTemplateMeta();
markDirty();
});
};
var saveTemplate = function (ev) {
if (ev && typeof ev.preventDefault === "function") { ev.preventDefault(); }
try {
if (templateJoditInstance && typeof templateJoditInstance.synchronizeValues === "function") {
templateJoditInstance.synchronizeValues();
}
} catch (e) {
}
try {
saveTemplateNow();
} catch (e) {
setTemplateError(e && e.message ? e.message : "Template save failed.");
}
};
templateSave.addEventListener("click", saveTemplate);
}
if (pdfAssetInsert && pdfAssetPick) {
pdfAssetInsert.addEventListener("click", function () {
var raw = pdfAssetPick.value || "";
if (!raw) { return; }
var token = "{{ pdf_asset(\\"" + normalizeAssetName(raw) + "\\") }}";
var token = "{{ pdf_asset(\\"" + escapeTwigDoubleQuotedString(raw) + "\\") }}";
insertToken(token);
});
}
@ -729,8 +705,9 @@ final class MailshotsAdminPage
if (csv) { csv.value = ""; }
currentTokens = [];
refreshTokenUi();
updateRecipientWarning();
updateTemplateMeta();
isDirty = false;
setCleanState();
editorModal.style.display = "block";
});
}
@ -784,14 +761,17 @@ final class MailshotsAdminPage
}
refreshTokenUi();
updateRecipientWarning();
updateTemplateMeta();
setCleanState();
var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . ';
var hasDraft = ' . (is_array($draft) ? 'true' : 'false') . ';
var hasResult = ' . ($result !== null ? 'true' : 'false') . ';
var resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . ';
if (hasEdit && editorModal && (!hasResult || !resultOk)) {
if ((hasEdit || hasDraft) && editorModal && (!hasResult || !resultOk)) {
editorModal.style.display = "block";
isDirty = false;
setCleanState();
}
})();
</script>';
@ -801,8 +781,7 @@ final class MailshotsAdminPage
public function handleUiSave(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
@ -810,7 +789,7 @@ final class MailshotsAdminPage
$attachmentCsv = (string) ($this->wp->requestParam('AttachmentNamesCsv', '') ?? '');
$attachmentNames = array_values(array_filter(array_map('trim', explode(',', $attachmentCsv)), static fn(string $v): bool => $v !== ''));
$payload = [
$formDraft = [
'Purpose' => (string) ($this->wp->requestParam('Purpose', '') ?? ''),
'DataSource' => (string) ($this->wp->requestParam('DataSource', '') ?? ''),
'CC' => (string) ($this->wp->requestParam('CC', '') ?? ''),
@ -818,13 +797,32 @@ final class MailshotsAdminPage
'Subject' => (string) ($this->wp->requestParam('Subject', '') ?? ''),
'Message' => (string) ($this->wp->requestParam('Message', '') ?? ''),
'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''),
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
'AttachmentNamesCsv' => $attachmentCsv,
'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''),
'RecipientEmailField' => (string) ($this->wp->requestParam('RecipientEmailField', '') ?? ''),
'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''),
];
$payload = [
'Purpose' => $formDraft['Purpose'],
'DataSource' => $formDraft['DataSource'],
'CC' => $formDraft['CC'],
'BCC' => $formDraft['BCC'],
'Subject' => $formDraft['Subject'],
'Message' => $formDraft['Message'],
'PDFAttachment' => $formDraft['PDFAttachment'],
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
'PDFFilenameDerivedFrom' => $formDraft['PDFFilenameDerivedFrom'],
'RecipientEmailField' => $formDraft['RecipientEmailField'],
'ReplyTo' => $formDraft['ReplyTo'],
];
$result = $this->service()->save($id, $payload);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$result['context'] = 'mailshot_save';
$this->persistResultAndDraft(
self::RESULT_OPTION_KEY,
self::DRAFT_OPTION_KEY,
$result,
['id' => $id, 'form' => $formDraft]
);
$editId = $id;
if (!empty($result['ok']) && isset($result['id'])) {
$editId = (int) $result['id'];
@ -838,8 +836,7 @@ final class MailshotsAdminPage
public function handleUiDelete(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
@ -849,7 +846,7 @@ final class MailshotsAdminPage
$this->service()->delete($id);
}
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => [$e->getMessage()]];
$result = ['ok' => false, 'errors' => [$e->getMessage()], 'context' => 'mailshot_delete'];
}
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
if (!headers_sent()) {
@ -896,6 +893,9 @@ final class MailshotsAdminPage
}
if ($op === 'clear_last_run') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$this->service()->clearLastRun($mailshotId);
$this->wp->sendJson(['ok' => true]);
@ -903,6 +903,9 @@ final class MailshotsAdminPage
}
if ($op === 'save') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = $this->wp->requestParam('id', '');
$id = $idRaw === '' ? null : (int) $idRaw;
$payload = [
@ -923,6 +926,9 @@ final class MailshotsAdminPage
}
if ($op === 'delete') {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$this->service()->delete($id);
$this->wp->sendJson(['ok' => true]);
@ -951,11 +957,4 @@ final class MailshotsAdminPage
return trim($label);
}
/** @return array<string,mixed>|null */
private function result(): ?array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null;
}
}

View File

@ -9,8 +9,13 @@ use FecaMailshots\WordPress\WordPressFacade;
final class PdfAssetsAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const RESULT_OPTION_KEY = 'feca_mailshots_pdf_assets_ui_result';
private const DRAFT_OPTION_KEY = 'feca_mailshots_pdf_assets_ui_draft';
private const PAGE_SLUG = 'feca-mailshots-pdf-assets';
private const NONCE_ACTION = 'feca_mailshots_pdf_assets';
/** @var callable(): PdfAssetService */
private $serviceFactory;
private WordPressFacade $wp;
@ -32,7 +37,7 @@ final class PdfAssetsAdminPage
public function registerMenu(): void
{
$this->wp->addSubmenuPage('feca-mailshot', 'PDF Assets', 'PDF Assets', self::CAPABILITY, 'feca-mailshots-pdf-assets', [$this, 'render']);
$this->wp->addSubmenuPage('feca-mailshot', 'PDF Assets', 'PDF Assets', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']);
}
public function render(): void
@ -42,7 +47,11 @@ final class PdfAssetsAdminPage
return;
}
$items = $this->service()->list();
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
$editId = (int) $draft['id'];
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['id'] ?? 0) === $editId) {
@ -50,14 +59,22 @@ final class PdfAssetsAdminPage
break;
}
}
$result = $this->result();
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$name = (string) ($editItem['name'] ?? '');
$fileName = (string) ($editItem['file_name'] ?? '');
$mime = (string) ($editItem['mime_type'] ?? 'application/pdf');
$width = (string) ($editItem['width_mm'] ?? '210');
$height = (string) ($editItem['height_mm'] ?? '297');
$just = (string) ($editItem['justification'] ?? 'in-place');
$base64 = '';
if (is_array($draft)) {
$name = (string) ($draft['name'] ?? $name);
$fileName = (string) ($draft['file_name'] ?? $fileName);
$width = (string) ($draft['width_mm'] ?? $width);
$height = (string) ($draft['height_mm'] ?? $height);
$just = (string) ($draft['justification'] ?? $just);
$base64 = (string) ($draft['file_bytes_base64'] ?? '');
}
echo '<div class="wrap"><h1>PDF Assets</h1>';
if ($result !== null) {
@ -79,7 +96,9 @@ final class PdfAssetsAdminPage
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="pdf-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="pdf-editor-id">';
} else {
@ -88,7 +107,6 @@ final class PdfAssetsAdminPage
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="pdf_name">Name</label></th><td><input class="regular-text" type="text" id="pdf_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_file_name">File Name</label></th><td><input class="regular-text" type="text" id="pdf_file_name" name="file_name" value="' . htmlspecialchars($fileName, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_mime">MIME Type</label></th><td><input class="regular-text" type="text" id="pdf_mime" name="mime_type" value="' . htmlspecialchars($mime, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_width">Width (mm)</label></th><td><input class="small-text" type="number" step="0.01" id="pdf_width" name="width_mm" value="' . htmlspecialchars($width, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_height">Height (mm)</label></th><td><input class="small-text" type="number" step="0.01" id="pdf_height" name="height_mm" value="' . htmlspecialchars($height, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_just">Justification</label></th><td><select id="pdf_just" name="justification">';
@ -97,12 +115,12 @@ final class PdfAssetsAdminPage
echo '<option value="' . htmlspecialchars($opt, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($opt) . '</option>';
}
echo '</select></td></tr>';
echo '<tr><th scope="row"><label for="pdf_file_upload">Upload PDF</label></th><td><input type="file" id="pdf_file_upload" name="file_upload" accept="application/pdf"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_base64">File Bytes (Base64)</label></th><td><textarea id="pdf_base64" name="file_bytes_base64" rows="4" class="large-text code"></textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '<tr><th scope="row"><label for="pdf_file_upload">Upload Image</label></th><td><input type="file" id="pdf_file_upload" name="file_upload" accept="image/*"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_base64">File Bytes (Base64)</label></th><td><textarea id="pdf_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update PDF Asset' : 'Create PDF Asset') . '</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets')) . '">Cancel Edit</a>';
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>';
}
echo '</p></form>';
echo '</div></div>';
@ -122,6 +140,7 @@ final class PdfAssetsAdminPage
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">';
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this PDF asset?\');">Delete</button>';
echo '</form></td>';
@ -131,57 +150,49 @@ final class PdfAssetsAdminPage
echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
}
echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");var form=document.getElementById("pdf-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
echo $this->modalEditorScript(
'pdf-editor-modal',
'pdf-open-new',
'pdf-close-editor',
'pdf-editor-form',
'pdf-editor-id',
['pdf_name', 'pdf_file_name', 'pdf_width', 'pdf_height', 'pdf_base64'],
$editId > 0,
is_array($draft),
$result !== null,
!empty($result['ok']),
'pdf_just',
'in-place',
'pdf_file_upload',
'pdf_file_name'
);
echo '</div>';
}
public function handleUiSave(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$idRaw = trim((string) ($this->wp->requestParam('id', '') ?? ''));
$id = $idRaw === '' ? null : (int) $idRaw;
$base64 = (string) ($this->wp->requestParam('file_bytes_base64', '') ?? '');
if ($base64 === '' && isset($_FILES['file_upload']) && is_array($_FILES['file_upload']) && (int) ($_FILES['file_upload']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$tmp = (string) ($_FILES['file_upload']['tmp_name'] ?? '');
if ($tmp !== '' && is_file($tmp)) {
$bytes = file_get_contents($tmp);
if ($bytes !== false) {
$base64 = base64_encode($bytes);
}
}
}
$payload = [
'name' => (string) ($this->wp->requestParam('name', '') ?? ''),
'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''),
'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/pdf') ?? 'application/pdf'),
'file_bytes_base64' => $base64,
'width_mm' => (string) ($this->wp->requestParam('width_mm', '0') ?? '0'),
'height_mm' => (string) ($this->wp->requestParam('height_mm', '0') ?? '0'),
'justification' => (string) ($this->wp->requestParam('justification', 'in-place') ?? 'in-place'),
];
$id = $this->requestOptionalInt('id');
$uploadedBase64 = $this->uploadedFileToBase64('file_upload');
$base64 = $uploadedBase64 !== null ? $uploadedBase64 : $this->requestString('file_bytes_base64', '');
$payload = $this->pdfAssetPayload($base64);
$result = $this->service()->save($id, $payload);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->persistResultAndDraft(self::RESULT_OPTION_KEY, self::DRAFT_OPTION_KEY, $result, ['id' => $id] + $payload);
$editId = $id;
if (!empty($result['ok']) && isset($result['id'])) {
$editId = (int) $result['id'];
}
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : ''));
if (!headers_sent()) {
header('Location: ' . $url, true, 302);
exit;
}
$this->redirectToAdminPage(self::PAGE_SLUG, $editId);
}
public function handleUiDelete(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$id = $this->requestInt('id', 0);
$result = ['ok' => true];
try {
if ($id > 0) {
@ -191,51 +202,38 @@ final class PdfAssetsAdminPage
$result = ['ok' => false, 'errors' => [$e->getMessage()]];
}
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
if (!headers_sent()) {
header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-pdf-assets'), true, 302);
exit;
}
$this->redirectToAdminPage(self::PAGE_SLUG);
}
public function handleApi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return;
}
$op = (string) ($this->wp->requestParam('op', 'list') ?? 'list');
$op = $this->requestString('op', 'list');
try {
if ($op === 'list') {
$this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]);
return;
$this->handleBasicCrudApi(
$op,
fn(): array => ['ok' => true, 'items' => $this->service()->list()],
function (): array {
if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) {
return ['ok' => false, 'error' => 'Mutation guard rejected request.'];
}
if ($op === 'save') {
$idRaw = $this->wp->requestParam('id', '');
$id = $idRaw === '' ? null : (int) $idRaw;
$payload = [
'name' => (string) ($this->wp->requestParam('name', '') ?? ''),
'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''),
'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'),
'file_bytes_base64' => (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''),
'width_mm' => (string) ($this->wp->requestParam('width_mm', '0') ?? '0'),
'height_mm' => (string) ($this->wp->requestParam('height_mm', '0') ?? '0'),
'justification' => (string) ($this->wp->requestParam('justification', 'in-place') ?? 'in-place'),
];
$this->wp->sendJson($this->service()->save($id, $payload));
return;
$id = $this->requestOptionalInt('id');
$payload = $this->pdfAssetPayload($this->requestString('file_bytes_base64', ''));
return $this->service()->save($id, $payload);
},
function (): array {
if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) {
return ['ok' => false, 'error' => 'Mutation guard rejected request.'];
}
if ($op === 'delete') {
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
$id = $this->requestInt('id', 0);
$this->service()->delete($id);
$this->wp->sendJson(['ok' => true]);
return;
return ['ok' => true];
}
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
);
} catch (\Throwable $e) {
$this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500);
}
@ -246,11 +244,18 @@ final class PdfAssetsAdminPage
return ($this->serviceFactory)();
}
/** @return array<string,mixed>|null */
private function result(): ?array
/** @return array{name:string,file_name:string,mime_type:string,file_bytes_base64:string,width_mm:string,height_mm:string,justification:string} */
private function pdfAssetPayload(string $base64): array
{
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null;
return [
'name' => $this->requestString('name', ''),
'file_name' => $this->requestString('file_name', ''),
'mime_type' => $this->requestString('mime_type', ''),
'file_bytes_base64' => $base64,
'width_mm' => $this->requestString('width_mm', '0'),
'height_mm' => $this->requestString('height_mm', '0'),
'justification' => $this->requestString('justification', 'in-place'),
];
}
}

View File

@ -9,8 +9,12 @@ use FecaMailshots\WordPress\WordPressFacade;
final class ProfileAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const TEST_RESULT_OPTION_PREFIX = 'feca_mailshots_profile_test_result_';
private const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
private const NONCE_ACTION = 'feca_mailshots_profile';
private WordPressFacade $wp;
/** @var callable(): MailCredentialRepository */
@ -44,6 +48,7 @@ final class ProfileAdminPage
$uid = $this->wp->currentUserId();
$saved = $uid > 0 ? ($this->repo()->findByUserId($uid) ?? []) : [];
$downloadMemoryLimit = trim((string) ($this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '')));
$status = $this->wp->requestParam('saved', '') === '1' ? 'Profile credentials saved.' : '';
$test = $uid > 0 ? $this->testResult($uid) : null;
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
@ -82,12 +87,13 @@ final class ProfileAdminPage
echo '</div>';
echo '<form method="post" action="' . $action . '">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<h2>SMTP</h2>';
$this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? '');
$this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? '587'));
$this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? ''));
$this->field('SMTP User', 'smtp_user', $saved['smtp_user'] ?? '');
$this->field('SMTP Password', 'smtp_password', '', 'password', 'Leave blank to keep existing password');
$this->field('SMTP Password', 'smtp_password', '', 'password', 'Required on each save/test request');
$this->field('From Email', 'smtp_from_email', $saved['smtp_from_email'] ?? '');
$this->field('From Name', 'smtp_from_name', $saved['smtp_from_name'] ?? '');
@ -96,11 +102,20 @@ final class ProfileAdminPage
echo '<h2>IMAP Sent Copy</h2>';
$this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? '');
$this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? '993'));
$this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? ''));
$this->field('IMAP User', 'imap_user', $saved['imap_user'] ?? '');
$this->field('IMAP Password', 'imap_password', '', 'password', 'Leave blank to keep existing password');
$this->field('IMAP Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? 'Sent');
$this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? '/imap/ssl');
$this->field('IMAP Password', 'imap_password', '', 'password', 'Required on each save/test request');
$this->field('IMAP Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? '');
$this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? '');
echo '<h2>Operational Settings</h2>';
$this->field(
'Download PDF Memory Limit',
'download_memory_limit',
$downloadMemoryLimit,
'text',
'PHP memory_limit value used for Download PDF actions (for example: 256M, 512M, 1G). Leave blank to use plugin default.'
);
echo '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_profile_save">Save Profile</button> ';
@ -113,8 +128,7 @@ final class ProfileAdminPage
public function handleSave(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
@ -126,22 +140,23 @@ final class ProfileAdminPage
$payload = [
'smtp_host' => trim((string) ($this->wp->requestParam('smtp_host', '') ?? '')),
'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '587') ?? '587')),
'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '') ?? '')),
'smtp_user' => trim((string) ($this->wp->requestParam('smtp_user', '') ?? '')),
'smtp_password' => (string) ($this->wp->requestParam('smtp_password', '') ?? ''),
'smtp_from_email' => trim((string) ($this->wp->requestParam('smtp_from_email', '') ?? '')),
'smtp_from_name' => trim((string) ($this->wp->requestParam('smtp_from_name', '') ?? '')),
'smtp_require_tls' => $this->wp->requestParam('smtp_require_tls', '') === '1',
'imap_host' => trim((string) ($this->wp->requestParam('imap_host', '') ?? '')),
'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '993') ?? '993')),
'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '') ?? '')),
'imap_user' => trim((string) ($this->wp->requestParam('imap_user', '') ?? '')),
'imap_password' => (string) ($this->wp->requestParam('imap_password', '') ?? ''),
'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', 'Sent') ?? 'Sent')),
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '/imap/ssl') ?? '/imap/ssl')),
'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', '') ?? '')),
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')),
];
$downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? ''));
$errors = [];
foreach (['smtp_host', 'smtp_user', 'smtp_from_email'] as $required) {
foreach (['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'imap_host', 'imap_port', 'imap_user', 'imap_password', 'imap_sent_folder', 'imap_mailbox_flags'] as $required) {
if ($payload[$required] === '') {
$errors[] = 'Missing required field: ' . $required;
}
@ -150,8 +165,16 @@ final class ProfileAdminPage
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
return;
}
if ($downloadMemoryLimit !== '' && $this->memoryLimitToBytes($downloadMemoryLimit) <= 0) {
$this->wp->sendJson([
'ok' => false,
'errors' => ['Invalid download_memory_limit. Use a PHP memory value such as 256M, 512M, or 1G.'],
], 400);
return;
}
$this->repo()->upsertForUser($uid, $payload);
$this->wp->updateOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
if (!headers_sent()) {
$location = $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&saved=1');
@ -164,8 +187,7 @@ final class ProfileAdminPage
public function handleTest(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
@ -192,30 +214,20 @@ final class ProfileAdminPage
$payload = [
'smtp_host' => trim((string) ($this->wp->requestParam('smtp_host', '') ?? '')),
'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '587') ?? '587')),
'smtp_port' => trim((string) ($this->wp->requestParam('smtp_port', '') ?? '')),
'smtp_user' => trim((string) ($this->wp->requestParam('smtp_user', '') ?? '')),
'smtp_password' => (string) ($this->wp->requestParam('smtp_password', '') ?? ''),
'smtp_from_email' => trim((string) ($this->wp->requestParam('smtp_from_email', '') ?? '')),
'smtp_from_name' => trim((string) ($this->wp->requestParam('smtp_from_name', '') ?? '')),
'smtp_require_tls' => $this->wp->requestParam('smtp_require_tls', '') === '1',
'imap_host' => trim((string) ($this->wp->requestParam('imap_host', '') ?? '')),
'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '993') ?? '993')),
'imap_port' => trim((string) ($this->wp->requestParam('imap_port', '') ?? '')),
'imap_user' => trim((string) ($this->wp->requestParam('imap_user', '') ?? '')),
'imap_password' => (string) ($this->wp->requestParam('imap_password', '') ?? ''),
'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', 'Sent') ?? 'Sent')),
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '/imap/ssl') ?? '/imap/ssl')),
'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', '') ?? '')),
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')),
];
$existing = $this->repo()->findByUserId($uid);
if (is_array($existing)) {
if ($payload['smtp_password'] === '') {
$payload['smtp_password'] = (string) ($existing['smtp_password'] ?? '');
}
if ($payload['imap_password'] === '') {
$payload['imap_password'] = (string) ($existing['imap_password'] ?? '');
}
}
$result = $kind === 'smtp' ? $this->runSmtpTest($payload) : $this->runImapTest($payload);
$this->wp->updateOption($this->testResultOptionKey($uid), $result);
@ -238,6 +250,34 @@ final class ProfileAdminPage
echo '</td></tr></table>';
}
private function memoryLimitToBytes(string $limit): int
{
$v = trim($limit);
if ($v === '') {
return 0;
}
if ($v === '-1') {
return -1;
}
$unit = strtolower(substr($v, -1));
if (ctype_alpha($unit)) {
$num = (float) substr($v, 0, -1);
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
return (int) $v;
}
private function repo(): MailCredentialRepository
{
return ($this->repoFactory)();
@ -311,7 +351,7 @@ final class ProfileAdminPage
private function runImapTest(array $payload): array
{
$messages = [];
foreach (['imap_host', 'imap_port', 'imap_user', 'imap_password'] as $required) {
foreach (['imap_host', 'imap_port', 'imap_user', 'imap_password', 'imap_sent_folder', 'imap_mailbox_flags'] as $required) {
if (trim((string) ($payload[$required] ?? '')) === '') {
return ['kind' => 'imap', 'ok' => false, 'messages' => ['Missing required field: ' . $required]];
}
@ -325,8 +365,8 @@ final class ProfileAdminPage
$port = (int) $payload['imap_port'];
$user = (string) $payload['imap_user'];
$pass = (string) $payload['imap_password'];
$folder = trim((string) ($payload['imap_sent_folder'] ?? 'Sent'));
$flags = trim((string) ($payload['imap_mailbox_flags'] ?? '/imap/ssl'));
$folder = trim((string) ($payload['imap_sent_folder'] ?? ''));
$flags = trim((string) ($payload['imap_mailbox_flags'] ?? ''));
foreach ($this->imapConfigDiagnostics($port, $flags) as $hint) {
$messages[] = 'Config check: ' . $hint;
}

View File

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\WordPress\WordPressFacade;
final class ReviewRecipientsAdminPage
{
use AdminRequestHelpers;
private const CAPABILITY = 'edit_pages';
private const PAGE_SLUG = 'feca-mailshots-review-recipients';
/** @var callable(): DataSourceService */
private $serviceFactory;
private WordPressFacade $wp;
/** @param callable(): DataSourceService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->serviceFactory = $serviceFactory;
$this->wp = $wp;
}
public function register(): void
{
$this->wp->addAction('admin_menu', [$this, 'registerMenu']);
$this->wp->addAction('admin_post_feca_mailshots_review_recipients_api', [$this, 'handleApi']);
}
public function registerMenu(): void
{
$this->wp->addSubmenuPage('feca-mailshot', 'Review Recipients', 'Review Recipients', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']);
}
public function render(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
echo 'Permission denied';
return;
}
$service = $this->service();
$sources = $service->list();
$selectedSource = trim((string) ($this->wp->requestParam('data_source', '') ?? ''));
$initialRows = [];
$initialColumns = [];
$initialCount = 0;
$initialErrors = [];
if ($selectedSource !== '') {
$dsl = $this->dslForSource($sources, $selectedSource);
if ($dsl === '') {
$initialErrors[] = 'Selected data source was not found.';
} else {
$result = $service->review($dsl);
$initialErrors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
if ($initialErrors === []) {
$initialRows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$initialColumns = is_array($result['columns'] ?? null) ? array_values(array_map('strval', $result['columns'])) : [];
$initialCount = (int) ($result['count'] ?? count($initialRows));
}
}
}
echo '<div class="wrap"><h1>Review Recipients</h1>';
echo '<p>Inspect recipient rows for a selected data source with full-field filtering and sorting.</p>';
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:320px;">';
echo '<label for="rr_data_source" style="white-space:nowrap;padding-left:4px;"><strong>Data Source</strong></label>';
echo '<select id="rr_data_source" style="min-width:220px;">';
echo '<option value="">Select data source</option>';
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === '') {
continue;
}
$selected = $name === $selectedSource ? ' selected' : '';
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>';
}
echo '</select></div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
echo '<label for="rr_filter" style="white-space:nowrap;padding-left:4px;"><strong>Filter by</strong></label>';
echo '<input id="rr_filter" class="regular-text" type="text" placeholder="Matches any field" style="min-width:240px;">';
echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">';
echo '<label for="rr_sort_by" style="white-space:nowrap;padding-left:4px;"><strong>Sort by</strong></label>';
echo '<select id="rr_sort_by" style="min-width:170px;"><option value="">(no fields)</option></select>';
echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:280px;">';
echo '<label for="rr_sort_direction" style="white-space:nowrap;padding-left:4px;"><strong>Sort Direction</strong></label>';
echo '<select id="rr_sort_direction" style="min-width:140px;"><option value="asc">Ascending</option><option value="desc">Descending</option></select>';
echo '</div>';
echo '</div></div>';
echo '<div id="rr_status" style="padding:10px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo 'Select a data source to load recipients.';
echo '</div>';
echo '<div id="rr_error" style="display:none;padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin-bottom:12px;"></div>';
echo '<div style="border:1px solid #dcdcde;background:#fff;max-width:calc(100vw - 80px);">';
echo '<div id="rr_scroll" style="overflow:scroll;max-height:68vh;scrollbar-gutter:stable both-edges;">';
echo '<table class="widefat striped" id="rr_table" style="min-width:100%;width:max-content;margin:0;border-collapse:separate;border-spacing:0;">';
echo '<colgroup id="rr_cols"></colgroup>';
echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>';
echo '<tbody id="rr_body"></tbody></table>';
echo '</div>';
echo '</div>';
$sourceDslMap = [];
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === '') {
continue;
}
$sourceDslMap[$name] = trim((string) ($source['dsl_text'] ?? ''));
}
echo '<script>';
echo 'window.fecaReviewRecipientsConfig = ' . json_encode([
'api' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_review_recipients_api'),
'pageSlug' => self::PAGE_SLUG,
'selectedSource' => $selectedSource,
'sources' => array_values(array_keys($sourceDslMap)),
'sourceDslMap' => $sourceDslMap,
'initial' => [
'ok' => $initialErrors === [],
'errors' => $initialErrors,
'rows' => $initialRows,
'columns' => $initialColumns,
'count' => $initialCount,
'source' => $selectedSource,
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo '(function(){';
echo 'var cfg=window.fecaReviewRecipientsConfig||{};';
echo 'var sourceSel=document.getElementById("rr_data_source");';
echo 'var filterInput=document.getElementById("rr_filter");';
echo 'var sortSel=document.getElementById("rr_sort_by");';
echo 'var dirSel=document.getElementById("rr_sort_direction");';
echo 'var statusEl=document.getElementById("rr_status");';
echo 'var errEl=document.getElementById("rr_error");';
echo 'var scrollWrap=document.getElementById("rr_scroll");';
echo 'var colsEl=document.getElementById("rr_cols");';
echo 'var headRow=document.getElementById("rr_head_row");';
echo 'var bodyEl=document.getElementById("rr_body");';
echo 'var cache={};';
echo 'var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedRowIndex:-1};';
echo 'function text(v){if(v===null||v===undefined){return "";}if(typeof v==="string"){return v;}if(typeof v==="number"||typeof v==="boolean"){return String(v);}try{return JSON.stringify(v);}catch(_){return String(v);}}';
echo 'function clearErr(){errEl.style.display="none";errEl.textContent="";}';
echo 'function showErr(msg){errEl.style.display="block";errEl.textContent=msg||"Unknown error";}';
echo 'function status(msg){statusEl.textContent=msg||"";}';
echo 'function setColumnWidths(cols){var widthPx=170;var c="";for(var i=0;i<cols.length;i++){c+="<col style=\\"width:"+widthPx+"px;min-width:"+widthPx+"px;max-width:"+widthPx+"px\\">";}colsEl.innerHTML=c;}';
echo 'function rebuildSortColumns(cols,keep){sortSel.innerHTML="";if(!cols||!cols.length){var o=document.createElement("option");o.value="";o.textContent="(no fields)";sortSel.appendChild(o);state.sortBy="";return;}cols.forEach(function(c,idx){var o=document.createElement("option");o.value=c;o.textContent=c;if((keep&&c===keep)||(!keep&&idx===0)){o.selected=true;state.sortBy=c;}sortSel.appendChild(o);});}';
echo 'function isIdField(name){var n=String(name||"").trim().toLowerCase();if(!n){return false;}return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}';
echo 'function normalizeColumns(rawCols,rows){var cols=(rawCols||[]).slice();if(cols.length===0&&rows&&rows.length){var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});}var seenOut={};var out=[];cols.forEach(function(c){var key=String(c||"").trim();if(!key||isIdField(key)){return;}var lk=key.toLowerCase();if(seenOut[lk]){return;}seenOut[lk]=1;out.push(key);});out.sort(function(a,b){return a.localeCompare(b,undefined,{sensitivity:"base"});});return out;}';
echo 'function rowMatches(row,needle,cols){if(!needle){return true;}var n=needle.toLowerCase();for(var i=0;i<cols.length;i++){var k=cols[i];if(text((row||{})[k]).toLowerCase().indexOf(n)!==-1){return true;}}return false;}';
echo 'function headerParts(name){var n=String(name||"");var idx=n.lastIndexOf(".");if(idx<=0||idx>=n.length-1){return {prefix:"",field:n};}return {prefix:n.substring(0,idx),field:n.substring(idx+1)};}';
echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).slice();var cols=normalizeColumns(payload.columns||[],rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0));rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}';
echo 'if(state.selectedRowIndex>=filtered.length){state.selectedRowIndex=-1;}setColumnWidths(cols);headRow.innerHTML="";if(cols.length===0){headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0));return;}cols.forEach(function(c){var th=document.createElement("th");th.style.position="sticky";th.style.top="0";th.style.zIndex="2";th.style.background="#f6f7f7";th.style.whiteSpace="normal";th.style.verticalAlign="bottom";th.style.maxWidth="170px";th.style.padding="8px";var p=headerParts(c);if(p.prefix){th.innerHTML="<div style=\\"line-height:1.2\\"><span style=\\"display:block;font-size:11px;color:#646970;word-break:break-word\\">"+p.prefix+"</span><span style=\\"display:block;font-size:12px;font-weight:600;word-break:break-word\\">"+p.field+"</span></div>";}else{th.innerHTML="<div style=\\"line-height:1.2\\"><span style=\\"display:block;font-size:12px;font-weight:600;word-break:break-word\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r,rowIndex){var tr=document.createElement("tr");tr.setAttribute("data-row-index",String(rowIndex));if(rowIndex===state.selectedRowIndex){tr.style.background="#e8f4ff";}cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.style.whiteSpace="nowrap";td.style.maxWidth="20ch";td.style.width="20ch";td.style.minWidth="20ch";td.style.overflow="hidden";td.style.textOverflow="ellipsis";td.title=v;td.textContent=v;if(rowIndex===state.selectedRowIndex){td.style.background="#e8f4ff";}tr.appendChild(td);});bodyEl.appendChild(tr);});status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+(state.filter?(" | Filter: "+state.filter):""));}';
echo 'function fetchSource(sourceName){if(!sourceName){state.source="";renderTable();return;}if(cache[sourceName]){state.source=sourceName;renderTable();return;}clearErr();status("Loading recipients...");var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName);fetch(url,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){var errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];cache[sourceName]={errors:errors,rows:[],columns:[],count:0};}else{cache[sourceName]={errors:[],rows:(j.rows||[]),columns:(j.columns||[]),count:(j.count||0)};}state.source=sourceName;renderTable();}).catch(function(e){cache[sourceName]={errors:[(e&&e.message)||"Request failed"],rows:[],columns:[],count:0};state.source=sourceName;renderTable();});}';
echo 'function updateUrlSource(sourceName){var url=new URL(window.location.href);url.searchParams.set("page",cfg.pageSlug||"feca-mailshots-review-recipients");if(sourceName){url.searchParams.set("data_source",sourceName);}else{url.searchParams.delete("data_source");}window.history.replaceState({}, "", url.toString());}';
echo 'sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;updateUrlSource(s);fetchSource(s);});';
echo 'filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});';
echo 'sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});';
echo 'dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});';
echo 'bodyEl.addEventListener("click",function(ev){var node=ev.target;while(node&&node.tagName!=="TR"){node=node.parentNode;}if(!node){return;}var idx=parseInt(node.getAttribute("data-row-index")||"-1",10);if(!isNaN(idx)&&idx>=0){state.selectedRowIndex=idx;renderTable();}});';
echo 'state.sortDirection="asc";dirSel.value="asc";';
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.ok){cache[cfg.initial.source]={errors:[],rows:(cfg.initial.rows||[]),columns:(cfg.initial.columns||[]),count:(cfg.initial.count||0)};}';
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.errors&&cfg.initial.errors.length){cache[cfg.initial.source]={errors:cfg.initial.errors,rows:[],columns:[],count:0};}';
echo 'var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;';
echo 'if(startSource){fetchSource(startSource);}else{renderTable();}';
echo '})();';
echo '</script>';
}
public function handleApi(): void
{
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return;
}
$op = $this->requestString('op', '');
if ($op !== 'load') {
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
return;
}
$sourceName = trim($this->requestString('data_source', ''));
if ($sourceName === '') {
$this->wp->sendJson(['ok' => false, 'errors' => ['data_source is required.']], 400);
return;
}
$sources = $this->service()->list();
$dsl = $this->dslForSource($sources, $sourceName);
if ($dsl === '') {
$this->wp->sendJson(['ok' => false, 'errors' => ['Selected data source was not found.']], 404);
return;
}
try {
$result = $this->service()->review($dsl);
$errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
if ($errors !== []) {
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
return;
}
$this->wp->sendJson([
'ok' => true,
'source' => $sourceName,
'rows' => is_array($result['rows'] ?? null) ? $result['rows'] : [],
'columns' => is_array($result['columns'] ?? null) ? $result['columns'] : [],
'count' => (int) ($result['count'] ?? 0),
]);
} catch (\Throwable $e) {
$this->wp->sendJson(['ok' => false, 'errors' => [$e->getMessage()]], 500);
}
}
/** @param list<array<string,mixed>> $sources */
private function dslForSource(array $sources, string $sourceName): string
{
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === $sourceName) {
return trim((string) ($source['dsl_text'] ?? ''));
}
}
return '';
}
private function service(): DataSourceService
{
return ($this->serviceFactory)();
}
}

View File

@ -10,8 +10,11 @@ use FecaMailshots\WordPress\WordPressFacade;
final class RunMailshotAdminPage
{
use AdminRequestHelpers;
private const RESULT_OPTION_KEY = 'feca_mailshots_run_ui_result';
private const CAPABILITY = 'edit_pages';
private const NONCE_ACTION = 'feca_mailshots_run';
/** @var callable(): MailshotRunService */
private $runServiceFactory;
@ -58,6 +61,8 @@ final class RunMailshotAdminPage
$result = $this->result();
$lastRunRows = $selectedMailshotId > 0 ? $this->mailshotService()->lastRun($selectedMailshotId) : [];
$recipientSummary = $selectedMailshotId > 0 ? $this->runService()->recipientCount($selectedMailshotId) : ['ok' => false, 'errors' => ['No mailshot selected.']];
$runCount = !empty($recipientSummary['ok']) ? (int) ($recipientSummary['count'] ?? 0) : 0;
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap"><h1>Run Mailshot</h1>';
@ -86,9 +91,12 @@ final class RunMailshotAdminPage
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;" id="run-mailshot-picker">';
echo '<input type="hidden" name="page" value="feca-mailshots-run">';
echo '<label>Mailshot: <select name="mailshot_id">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
echo '<label for="run_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
echo '<select id="run_mailshot_id" name="mailshot_id" onchange="document.getElementById(\'run-mailshot-picker\').submit();">';
foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0);
$sel = $id === $selectedMailshotId ? ' selected' : '';
@ -98,14 +106,20 @@ final class RunMailshotAdminPage
}
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select></label> <button class="button" type="submit">Load</button>';
echo '</select></div></div>';
echo '</form>';
if (!empty($recipientSummary['ok'])) {
echo '<p style="margin-top:8px;"><strong>Recipient rows:</strong> ' . (int) ($recipientSummary['count'] ?? 0) . '</p>';
} elseif (!empty($recipientSummary['errors']) && is_array($recipientSummary['errors'])) {
echo '<p style="margin-top:8px;color:#b32d2e;"><strong>Recipient count failed:</strong> ' . htmlspecialchars(implode('; ', $recipientSummary['errors'])) . '</p>';
}
echo '</div>';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">2. Run Actions</h2>';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<button class="button button-primary" type="submit" name="action" value="feca_mailshots_run_execute_ui" onclick="return confirm(\'Run mailshot now for the selected recipients?\');">Run Mailshot</button> ';
echo '<button class="button button-primary" type="submit" name="action" value="feca_mailshots_run_execute_ui" onclick="return confirm(\'Run Mailshot for ' . $runCount . ' recipients?\');">Run Mailshot</button> ';
echo '<button class="button" type="submit" name="action" value="feca_mailshots_retry_failed_ui" onclick="return confirm(\'Retry all failed recipients for this mailshot?\');">Retry Failed Sends</button>';
echo '</form>';
@ -129,6 +143,7 @@ final class RunMailshotAdminPage
if ($status === 'failed') {
echo '<form method="post" action="' . $action . '">';
echo '<input type="hidden" name="action" value="feca_mailshots_retry_recipient_ui">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<input type="hidden" name="recipient_key" value="' . htmlspecialchars($recipientKey, ENT_QUOTES) . '">';
echo '<button class="button" type="submit" onclick="return confirm(\'Retry this failed recipient now?\');">Retry</button>';
@ -146,8 +161,7 @@ final class RunMailshotAdminPage
public function handleApi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
@ -182,8 +196,7 @@ final class RunMailshotAdminPage
public function handleRunUi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
@ -194,8 +207,7 @@ final class RunMailshotAdminPage
public function handleRetryFailedUi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
@ -206,8 +218,7 @@ final class RunMailshotAdminPage
public function handleRetryRecipientUi(): void
{
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');

View File

@ -8,8 +8,11 @@ use FecaMailshots\WordPress\WordPressFacade;
final class SetupAdminPage
{
use AdminRequestHelpers;
public const OPTION_KEY = 'feca_mailshots_db_settings';
public const TEST_RESULT_OPTION_KEY = 'feca_mailshots_db_test_result';
private const NONCE_ACTION = 'feca_mailshots_setup';
private WordPressFacade $wp;
@ -65,6 +68,7 @@ final class SetupAdminPage
}
echo '<p>Configure database credentials used by FECA Mailshots in this WordPress environment.</p>';
echo '<form method="post" action="' . $action . '">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
$this->field('Host', 'db_host', $saved['db_host'] ?? '');
$this->field('Port', 'db_port', $saved['db_port'] ?? '3306');
@ -83,8 +87,7 @@ final class SetupAdminPage
public function handleSave(): void
{
if (!$this->wp->currentUserCan('manage_options')) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson('manage_options', self::NONCE_ACTION)) {
return;
}
@ -110,8 +113,7 @@ final class SetupAdminPage
public function handleTest(): void
{
if (!$this->wp->currentUserCan('manage_options')) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
if (!$this->enforceMutationGuardOrJson('manage_options', self::NONCE_ACTION)) {
return;
}

View File

@ -26,7 +26,10 @@ final class AttachmentService
{
$name = trim((string) ($payload['name'] ?? ''));
$fileName = trim((string) ($payload['file_name'] ?? ''));
$mimeType = trim((string) ($payload['mime_type'] ?? 'application/octet-stream'));
$mimeType = trim((string) ($payload['mime_type'] ?? ''));
if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename($fileName);
}
$base64 = (string) ($payload['file_bytes_base64'] ?? '');
$errors = [];
@ -36,6 +39,9 @@ final class AttachmentService
if ($fileName === '') {
$errors[] = 'file_name is required.';
}
if ($mimeType === '') {
$errors[] = 'mime_type is required (or inferable from file_name).';
}
$fileBytes = base64_decode($base64, true);
if ($base64 === '' || $fileBytes === false) {
@ -66,4 +72,37 @@ final class AttachmentService
{
$this->repo->delete($id);
}
private function inferMimeTypeFromFilename(string $fileName): string
{
$fileName = trim($fileName);
if ($fileName === '') {
return '';
}
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '') {
return '';
}
$map = [
'txt' => 'text/plain',
'csv' => 'text/csv',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'odt' => 'application/vnd.oasis.opendocument.text',
'rtf' => 'application/rtf',
'html' => 'text/html',
'htm' => 'text/html',
'json' => 'application/json',
'xml' => 'application/xml',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'zip' => 'application/zip',
];
return $map[$ext] ?? '';
}
}

View File

@ -113,6 +113,18 @@ final class DataSourceService
public function preview(string $dsl, int $limit = 50): array
{
$limit = max(1, min(200, $limit));
return $this->queryRows($dsl, $limit);
}
/** @return array<string, mixed> */
public function review(string $dsl): array
{
return $this->queryRows($dsl, null);
}
/** @return array<string, mixed> */
private function queryRows(string $dsl, ?int $limit): array
{
$validation = $this->validateDsl($dsl);
if ($validation['errors'] !== []) {
return ['errors' => $validation['errors'], 'warnings' => $validation['warnings'], 'rows' => [], 'count' => 0, 'columns' => []];
@ -126,10 +138,14 @@ final class DataSourceService
$stmtCount->execute($compiled['params']);
$count = (int) $stmtCount->fetchColumn();
$previewSql = $compiled['sql'] . ' LIMIT ' . $limit;
$previewSql = $compiled['sql'];
if ($limit !== null) {
$previewSql .= ' LIMIT ' . $limit;
}
$stmtRows = $this->router->membersPdo()->prepare($previewSql);
$stmtRows->execute($compiled['params']);
$rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC);
$rows = $this->withExpectedFieldAliases($rows, $validation['expected_fields']);
$columnSet = [];
foreach ($validation['expected_fields'] as $field) {
@ -151,6 +167,74 @@ final class DataSourceService
];
}
/**
* Ensure preview rows expose the same qualified field names used by token generation.
*
* @param list<array<string,mixed>> $rows
* @param list<string> $expectedFields
* @return list<array<string,mixed>>
*/
private function withExpectedFieldAliases(array $rows, array $expectedFields): array
{
if ($rows === [] || $expectedFields === []) {
return $rows;
}
$expectedByLeaf = [];
foreach ($expectedFields as $qualified) {
$qualified = trim((string) $qualified);
if ($qualified === '') {
continue;
}
$leaf = $this->leafFieldName($qualified);
if ($leaf === '') {
continue;
}
$expectedByLeaf[strtolower($leaf)][] = $qualified;
}
if ($expectedByLeaf === []) {
return $rows;
}
foreach ($rows as &$row) {
if (!is_array($row) || $row === []) {
continue;
}
$keyLookup = [];
foreach (array_keys($row) as $key) {
$k = (string) $key;
$keyLookup[strtolower($k)] = $k;
}
foreach ($expectedByLeaf as $leafLower => $qualifiedList) {
if (!isset($keyLookup[$leafLower])) {
continue;
}
$sourceKey = $keyLookup[$leafLower];
foreach ($qualifiedList as $qualified) {
if (array_key_exists($qualified, $row)) {
continue;
}
$row[$qualified] = $row[$sourceKey];
}
}
}
unset($row);
return $rows;
}
private function leafFieldName(string $qualifiedField): string
{
$pos = strrpos($qualifiedField, '.');
if ($pos === false) {
return $qualifiedField;
}
return substr($qualifiedField, $pos + 1);
}
/** @return array<string, list<string>> */
public function sourceFields(): array
{
@ -170,9 +254,6 @@ final class DataSourceService
$schemas[] = $membersSchema;
}
// Shared-hosting DB users often cannot read information_schema.
// Keep working with the configured members DB schema in that case.
try {
$sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name';
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
@ -182,9 +263,6 @@ final class DataSourceService
}
$schemas[] = $name;
}
} catch (\Throwable $e) {
// no-op: fallback is the configured members schema above
}
$schemas = array_values(array_unique($schemas));
sort($schemas, SORT_NATURAL | SORT_FLAG_CASE);

View File

@ -42,6 +42,13 @@ final class DslCompiler
. ' ON ' . $leftRef . ' = ' . $rightRef;
}
if (in_array('accounts', $sources, true)) {
$acc = $this->alias('accounts');
$joins[] = 'LEFT JOIN `picklist_account_type` AS `p_account_type` ON `p_account_type`.`id` = ' . $acc . '.`account_type_id`';
$joins[] = 'LEFT JOIN `picklist_public_location` AS `p_public_location` ON `p_public_location`.`id` = ' . $acc . '.`public_location_id`';
$joins[] = 'LEFT JOIN `picklist_sector` AS `p_sector` ON `p_sector`.`id` = ' . $acc . '.`sector_id`';
}
$params = [];
$whereParts = [];
foreach ($ast['where'] as $predicate) {
@ -167,6 +174,12 @@ final class DslCompiler
/** @param array{source:string,field:string} $fieldRef */
private function fieldRefSql(array $fieldRef): string
{
if ($fieldRef['source'] === 'accounts') {
$virtual = $this->accountsVirtualFieldSql($fieldRef['field']);
if ($virtual !== null) {
return $virtual;
}
}
return $this->alias($fieldRef['source']) . '.`' . $fieldRef['field'] . '`';
}
@ -191,6 +204,13 @@ final class DslCompiler
foreach ($sources as $source) {
$alias = $this->alias($source);
foreach ($this->metadata->sourceFields($source) as $field) {
if ($source === 'accounts') {
$virtual = $this->accountsVirtualFieldSql($field);
if ($virtual !== null) {
$parts[] = $virtual . ' AS `' . $source . '.' . $field . '`';
continue;
}
}
$parts[] = $alias . '.`' . $field . '` AS `' . $source . '.' . $field . '`';
}
}
@ -199,4 +219,19 @@ final class DslCompiler
}
return implode(', ', $parts);
}
private function accountsVirtualFieldSql(string $field): ?string
{
$name = strtolower(trim($field));
if ($name === 'type') {
return '`p_account_type`.`value`';
}
if ($name === 'public_location') {
return '`p_public_location`.`value`';
}
if ($name === 'account_sector') {
return '`p_sector`.`value`';
}
return null;
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
@ -12,6 +13,7 @@ final class MailshotRunService
{
private MailshotRepository $mailshots;
private MailshotQueryRepository $queries;
private AttachmentRepository $attachments;
private DataSourceService $dataSources;
private TemplateRenderer $renderer;
private SmtpSender $smtp;
@ -22,6 +24,7 @@ final class MailshotRunService
public function __construct(
MailshotRepository $mailshots,
MailshotQueryRepository $queries,
AttachmentRepository $attachments,
DataSourceService $dataSources,
TemplateRenderer $renderer,
SmtpSender $smtp,
@ -31,6 +34,7 @@ final class MailshotRunService
) {
$this->mailshots = $mailshots;
$this->queries = $queries;
$this->attachments = $attachments;
$this->dataSources = $dataSources;
$this->renderer = $renderer;
$this->smtp = $smtp;
@ -50,7 +54,7 @@ final class MailshotRunService
public function previewRecipients(int $mailshotId, int $limit = 100): array
{
try {
[, $rows] = $this->loadMailshotAndRows($mailshotId);
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []];
}
@ -73,15 +77,46 @@ final class MailshotRunService
return ['ok' => true, 'rows' => $out, 'count' => count($out)];
}
/** @return array{ok:bool,count?:int,errors?:list<string>} */
public function recipientCount(int $mailshotId): array
{
try {
$mailshot = $this->mailshots->find($mailshotId);
if ($mailshot === null) {
throw new \RuntimeException('Mailshot not found.');
}
$dataSourceName = trim((string) ($mailshot['DataSource'] ?? ''));
$query = $this->queries->findByName($dataSourceName);
if ($query === null) {
throw new \RuntimeException('Mailshot data source is missing.');
}
$dsl = trim((string) ($query['dsl_text'] ?? ''));
if ($dsl === '') {
throw new \RuntimeException('Data source DSL is empty.');
}
$preview = $this->dataSources->preview($dsl, 1);
if (($preview['errors'] ?? []) !== []) {
throw new \RuntimeException('Data source preview failed: ' . implode('; ', (array) $preview['errors']));
}
return ['ok' => true, 'count' => (int) ($preview['count'] ?? 0)];
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()]];
}
}
/** @return array<string, mixed> */
public function renderTest(int $mailshotId, int $recipientIndex): array
{
try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
if (!isset($rows[$recipientIndex])) {
return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
}
try {
$rendered = $this->renderer->render(
(string) $mailshot['Subject'],
(string) $mailshot['Message'],
@ -103,6 +138,7 @@ final class MailshotRunService
return ['ok' => false, 'errors' => ['Test email address is required.']];
}
try {
$creds = $this->credentials->credentials();
if ($creds === null) {
return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
@ -120,11 +156,13 @@ final class MailshotRunService
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
$attachments = array_merge(
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, (array) ($render['recipient'] ?? []), (array) ($render['rendered'] ?? []))
);
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
try {
$send = $this->smtp->send(
$creds,
[$testEmail],
@ -132,12 +170,9 @@ final class MailshotRunService
$bcc,
(string) $render['rendered']['subject'],
(string) $render['rendered']['message'],
(string) ($mailshot['ReplyTo'] ?? '')
(string) ($mailshot['ReplyTo'] ?? ''),
$attachments
);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['SMTP send failed: ' . $e->getMessage()]];
}
$warnings = [];
try {
$this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId);
@ -150,8 +185,10 @@ final class MailshotRunService
'warnings' => $warnings,
'sent_to' => $testEmail,
'sent_at' => gmdate('c'),
'rendered' => $render['rendered'],
];
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
}
}
/** @return array<string, mixed> */
@ -233,6 +270,445 @@ final class MailshotRunService
return $this->executeSendLoop($mailshotId, $mailshot, [$row], $creds, false);
}
/** @return array<string,mixed> */
public function generatePdfBatch(int $mailshotId, bool $includeMerged = true, bool $includeFiles = true): array
{
$this->downloadDebugLog('generatePdfBatch.start', [
'mailshot_id' => $mailshotId,
'include_merged' => $includeMerged,
'include_files' => $includeFiles,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
if (!$includeMerged && !$includeFiles) {
return ['ok' => false, 'errors' => ['PDF generation requested no outputs.']];
}
try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()]];
}
if ($rows === []) {
return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
}
$files = [];
$mergedHtmlTmpPath = null;
$mergedHtmlHandle = null;
$ghostscriptBinary = $this->findExecutableBinary('gs', ['/usr/bin/gs', '/bin/gs']);
$useGhostscriptMerge = $includeMerged && $ghostscriptBinary !== '' && function_exists('exec');
$mergedPdfTmpDir = '';
$mergedPdfPartPaths = [];
$nameCounts = [];
$errors = [];
$skipped = 0;
$mergedSectionCount = 0;
if ($useGhostscriptMerge) {
$mergedPdfTmpDir = sys_get_temp_dir() . '/feca_mailshots_merged_parts_' . str_replace('.', '_', uniqid('', true));
if (!@mkdir($mergedPdfTmpDir, 0700, true) && !is_dir($mergedPdfTmpDir)) {
return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for merged PDF build.']];
}
$this->downloadDebugLog('generatePdfBatch.merged.ghostscript_enabled', [
'mailshot_id' => $mailshotId,
'ghostscript' => $ghostscriptBinary,
'tmp_dir' => $mergedPdfTmpDir,
]);
} elseif ($includeMerged) {
$tmpBasePath = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_html_');
if (!is_string($tmpBasePath) || $tmpBasePath === '') {
return ['ok' => false, 'errors' => ['Unable to allocate temporary merged HTML file.']];
}
$mergedHtmlTmpPath = $tmpBasePath . '.html';
if (!@rename($tmpBasePath, $mergedHtmlTmpPath)) {
@unlink($tmpBasePath);
return ['ok' => false, 'errors' => ['Unable to prepare temporary merged HTML file.']];
}
$mergedHtmlHandle = @fopen($mergedHtmlTmpPath, 'wb');
if (!is_resource($mergedHtmlHandle)) {
@unlink($mergedHtmlTmpPath);
return ['ok' => false, 'errors' => ['Unable to open temporary merged HTML file for writing.']];
}
fwrite(
$mergedHtmlHandle,
'<!doctype html><html><head><meta charset="UTF-8"><style>'
. '.feca-mailshot-pdf-section{page-break-after:always;}'
. '.feca-mailshot-pdf-section:last-child{page-break-after:auto;}'
. '</style></head><body>'
);
}
foreach (array_values($rows) as $index => $row) {
try {
$rendered = $this->renderer->render(
(string) $mailshot['Subject'],
(string) $mailshot['Message'],
(string) ($mailshot['PDFAttachment'] ?? ''),
(array) $row
);
$pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
if ($pdfHtml === '') {
$skipped++;
continue;
}
if ($includeFiles) {
$baseName = $this->pdfFilename($mailshot, (array) $row);
$filename = $this->uniqueFilename($baseName, $nameCounts);
$pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
$files[] = [
'filename' => $filename,
'content_bytes' => $pdfBytes,
];
}
if ($useGhostscriptMerge) {
$pdfBytesForMerge = $this->renderPdfBytesFromHtml($pdfHtml);
$partPath = $mergedPdfTmpDir . '/part_' . sprintf('%05d', (int) $index) . '.pdf';
if (@file_put_contents($partPath, $pdfBytesForMerge) === false) {
throw new \RuntimeException('Unable to write temporary merged PDF part.');
}
$mergedPdfPartPaths[] = $partPath;
$mergedSectionCount++;
unset($pdfBytesForMerge);
} elseif ($includeMerged) {
fwrite($mergedHtmlHandle, '<div class="feca-mailshot-pdf-section">' . $pdfHtml . '</div>');
$mergedSectionCount++;
}
unset($rendered, $pdfHtml);
} catch (\Throwable $e) {
[$keyField, $keyValue] = $this->detectRecipientKey((array) $row, $index);
$errors[] = 'Recipient ' . $keyField . '=' . $keyValue . ': ' . $e->getMessage();
}
if (($index % 10) === 0 && function_exists('gc_collect_cycles')) {
gc_collect_cycles();
}
if (($index % 10) === 0) {
$this->downloadDebugLog('generatePdfBatch.progress', [
'mailshot_id' => $mailshotId,
'row_index' => $index,
'generated_files' => count($files),
'merged_sections' => $mergedSectionCount,
'skipped' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
}
}
if ($includeMerged && is_resource($mergedHtmlHandle)) {
fwrite($mergedHtmlHandle, '</body></html>');
fclose($mergedHtmlHandle);
$mergedHtmlHandle = null;
}
if ($errors !== []) {
if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath);
}
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
return ['ok' => false, 'errors' => $errors];
}
if ($includeFiles && $files === []) {
if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath);
}
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
return ['ok' => false, 'errors' => ['No PDF attachments were generated from this mailshot.']];
}
if ($includeMerged && $mergedSectionCount === 0) {
if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath);
}
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
return ['ok' => false, 'errors' => ['No merged PDF content was generated from this mailshot.']];
}
$mergedPdfBytes = '';
if ($includeMerged) {
if ($useGhostscriptMerge) {
try {
$this->downloadDebugLog('generatePdfBatch.merged.before_ghostscript', [
'mailshot_id' => $mailshotId,
'parts' => count($mergedPdfPartPaths),
]);
$mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
} catch (\Throwable $e) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
$this->downloadDebugLog('generatePdfBatch.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
}
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
} else {
try {
$this->downloadDebugLog('generatePdfBatch.merged.before_render', [
'mailshot_id' => $mailshotId,
'merged_html_path' => (string) $mergedHtmlTmpPath,
'merged_sections' => $mergedSectionCount,
]);
$mergedPdfBytes = $this->renderPdfBytesFromHtmlFile((string) $mergedHtmlTmpPath);
$this->downloadDebugLog('generatePdfBatch.merged.after_render', [
'mailshot_id' => $mailshotId,
'bytes_len' => strlen($mergedPdfBytes),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) {
if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath);
}
$this->downloadDebugLog('generatePdfBatch.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
}
if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath);
}
}
}
$this->downloadDebugLog('generatePdfBatch.done', [
'mailshot_id' => $mailshotId,
'recipient_count' => count($rows),
'generated_count' => $includeFiles ? count($files) : $mergedSectionCount,
'skipped_count' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
return [
'ok' => true,
'recipient_count' => count($rows),
'generated_count' => $includeFiles ? count($files) : $mergedSectionCount,
'skipped_count' => $skipped,
'files' => $files,
'merged_pdf_bytes' => $mergedPdfBytes,
];
}
/** @return array<string,mixed> */
public function generatePdfZipToTemp(int $mailshotId): array
{
$this->downloadDebugLog('generatePdfZipToTemp.start', [
'mailshot_id' => $mailshotId,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
$canUseZipArchive = class_exists('ZipArchive');
$zipBinary = $this->findExecutableBinary('zip', ['/usr/bin/zip', '/bin/zip']);
if (!$canUseZipArchive && $zipBinary === '') {
return ['ok' => false, 'errors' => ['Neither ZipArchive extension nor zip CLI binary is available.']];
}
try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()]];
}
if ($rows === []) {
return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
}
$tmpZipBase = tempnam(sys_get_temp_dir(), 'feca_mailshots_zip_');
if (!is_string($tmpZipBase) || $tmpZipBase === '') {
return ['ok' => false, 'errors' => ['Unable to allocate temporary ZIP file.']];
}
@unlink($tmpZipBase);
$tmpZipPath = $tmpZipBase . '.zip';
if (is_file($tmpZipPath)) {
@unlink($tmpZipPath);
}
$tmpPdfDir = sys_get_temp_dir() . '/feca_mailshots_zip_' . str_replace('.', '_', uniqid('', true));
if (!@mkdir($tmpPdfDir, 0700, true) && !is_dir($tmpPdfDir)) {
@unlink($tmpZipPath);
return ['ok' => false, 'errors' => ['Unable to open ZIP archive for writing.']];
}
$nameCounts = [];
$errors = [];
$generated = 0;
$skipped = 0;
$generatedPdfNames = [];
try {
foreach (array_values($rows) as $index => $row) {
try {
$rendered = $this->renderer->render(
(string) $mailshot['Subject'],
(string) $mailshot['Message'],
(string) ($mailshot['PDFAttachment'] ?? ''),
(array) $row
);
$pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
if ($pdfHtml === '') {
$skipped++;
continue;
}
$baseName = $this->pdfFilename($mailshot, (array) $row);
$filename = $this->uniqueFilename($baseName, $nameCounts);
$pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
if ($pdfBytes === '') {
throw new \RuntimeException('Rendered PDF attachment is empty.');
}
$tmpPdfPath = $tmpPdfDir . '/' . $filename;
if (@file_put_contents($tmpPdfPath, $pdfBytes) === false) {
throw new \RuntimeException('Unable to write temporary PDF file.');
}
$generatedPdfNames[] = $filename;
unset($pdfBytes);
$generated++;
unset($rendered, $pdfHtml);
} catch (\Throwable $e) {
[$keyField, $keyValue] = $this->detectRecipientKey((array) $row, $index);
$errors[] = 'Recipient ' . $keyField . '=' . $keyValue . ': ' . $e->getMessage();
}
if (($index % 5) === 0 && function_exists('gc_collect_cycles')) {
gc_collect_cycles();
}
if (($index % 5) === 0 && function_exists('gc_mem_caches')) {
gc_mem_caches();
}
if (($index % 10) === 0) {
$this->downloadDebugLog('generatePdfZipToTemp.progress', [
'mailshot_id' => $mailshotId,
'row_index' => $index,
'generated' => $generated,
'skipped' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
}
}
if ($errors !== []) {
foreach ($generatedPdfNames as $name) {
@unlink($tmpPdfDir . '/' . $name);
}
@rmdir($tmpPdfDir);
@unlink($tmpZipPath);
return ['ok' => false, 'errors' => $errors];
}
if ($generated === 0) {
@rmdir($tmpPdfDir);
@unlink($tmpZipPath);
return ['ok' => false, 'errors' => ['No PDF attachments were generated from this mailshot.']];
}
if ($canUseZipArchive) {
$this->downloadDebugLog('generatePdfZipToTemp.ziparchive.begin', [
'mailshot_id' => $mailshotId,
'zip_path' => $tmpZipPath,
'file_count' => count($generatedPdfNames),
]);
$zip = new \ZipArchive();
$opened = $zip->open($tmpZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($opened !== true) {
throw new \RuntimeException('Unable to open ZIP archive for writing.');
}
foreach ($generatedPdfNames as $name) {
if (!$zip->addFile($tmpPdfDir . '/' . $name, $name)) {
throw new \RuntimeException('Failed to add ZIP entry "' . $name . '".');
}
}
$zip->close();
} else {
$this->downloadDebugLog('generatePdfZipToTemp.zipcli.begin', [
'mailshot_id' => $mailshotId,
'zip_path' => $tmpZipPath,
'file_count' => count($generatedPdfNames),
]);
if ($zipBinary === '') {
throw new \RuntimeException('zip CLI binary is not available.');
}
if (!function_exists('exec')) {
throw new \RuntimeException('zip CLI fallback requires exec(), but exec() is unavailable.');
}
$cmd = 'cd ' . escapeshellarg($tmpPdfDir)
. ' && ' . escapeshellarg($zipBinary)
. ' -q -X ' . escapeshellarg($tmpZipPath);
foreach ($generatedPdfNames as $name) {
$cmd .= ' ' . escapeshellarg($name);
}
$cmd .= ' 2>&1';
$output = [];
$code = 0;
@exec($cmd, $output, $code);
if ($code !== 0) {
throw new \RuntimeException(
'zip CLI failed' . ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.')
);
}
}
foreach ($generatedPdfNames as $name) {
@unlink($tmpPdfDir . '/' . $name);
}
@rmdir($tmpPdfDir);
$size = @filesize($tmpZipPath);
if (!is_int($size) || $size <= 0) {
@unlink($tmpZipPath);
return ['ok' => false, 'errors' => ['Generated ZIP file is empty.']];
}
return [
'ok' => true,
'recipient_count' => count($rows),
'generated_count' => $generated,
'skipped_count' => $skipped,
'zip_path' => $tmpZipPath,
'zip_size' => $size,
];
} catch (\Throwable $e) {
$this->downloadDebugLog('generatePdfZipToTemp.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
foreach ($generatedPdfNames as $name) {
@unlink($tmpPdfDir . '/' . $name);
}
@rmdir($tmpPdfDir);
@unlink($tmpZipPath);
return ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]];
}
}
private function findExecutableBinary(string $binaryName, array $fallbackPaths = []): string
{
$pathEnv = (string) getenv('PATH');
$candidates = [];
if ($pathEnv !== '') {
foreach (explode(':', $pathEnv) as $dir) {
$dir = trim($dir);
if ($dir !== '') {
$candidates[] = rtrim($dir, '/') . '/' . $binaryName;
}
}
}
foreach ($fallbackPaths as $p) {
$candidates[] = $p;
}
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '' && is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
}
return '';
}
/** @return array{0:array<string,mixed>,1:list<array<string,mixed>>} */
private function loadMailshotAndRows(int $mailshotId): array
{
@ -323,6 +799,10 @@ final class MailshotRunService
}
try {
$attachments = array_merge(
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, $row, $render)
);
$smtp = $this->smtp->send(
$creds,
[$recipientEmail],
@ -330,7 +810,8 @@ final class MailshotRunService
$bcc,
(string) $render['subject'],
(string) $render['message'],
(string) ($mailshot['ReplyTo'] ?? '')
(string) ($mailshot['ReplyTo'] ?? ''),
$attachments
);
$warning = null;
@ -458,4 +939,312 @@ final class MailshotRunService
}
return array_values(array_unique($out));
}
/**
* @param array<string,mixed> $mailshot
* @param array<string,mixed> $row
* @param array<string,mixed> $rendered
* @return list<array{filename:string,mime_type:string,content_bytes:string}>
*/
private function renderedPdfAttachments(array $mailshot, array $row, array $rendered): array
{
$pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
if ($pdfHtml === '') {
return [];
}
$pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
return [[
'filename' => $this->pdfFilename($mailshot, $row),
'mime_type' => 'application/pdf',
'content_bytes' => $pdfBytes,
]];
}
private function renderPdfBytesFromHtml(string $pdfHtml): string
{
if (!class_exists('Dompdf\\Dompdf')) {
throw new \RuntimeException('Dompdf is not available. Ensure vendor dependencies are installed.');
}
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($pdfHtml, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$pdfBytes = $dompdf->output();
if (!is_string($pdfBytes) || $pdfBytes === '') {
throw new \RuntimeException('Rendered PDF attachment is empty.');
}
return $pdfBytes;
}
private function renderPdfBytesFromHtmlFile(string $htmlPath): string
{
if (!class_exists('Dompdf\\Dompdf')) {
throw new \RuntimeException('Dompdf is not available. Ensure vendor dependencies are installed.');
}
if (!is_file($htmlPath)) {
throw new \RuntimeException('Merged HTML file not found.');
}
$html = @file_get_contents($htmlPath);
if (!is_string($html) || $html === '') {
throw new \RuntimeException('Merged HTML file is empty or unreadable.');
}
$dompdf = new \Dompdf\Dompdf();
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$pdfBytes = $dompdf->output();
if (!is_string($pdfBytes) || $pdfBytes === '') {
throw new \RuntimeException('Rendered merged PDF is empty.');
}
return $pdfBytes;
}
/**
* @param list<string> $pdfPaths
*/
private function mergePdfFilesWithGhostscript(string $ghostscriptBinary, array $pdfPaths): string
{
if ($ghostscriptBinary === '') {
throw new \RuntimeException('Ghostscript binary is not available.');
}
if (!function_exists('exec')) {
throw new \RuntimeException('Ghostscript merge requires exec(), but exec() is unavailable.');
}
if ($pdfPaths === []) {
throw new \RuntimeException('No PDF parts available for merged output.');
}
$tmpBase = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_pdf_');
if (!is_string($tmpBase) || $tmpBase === '') {
throw new \RuntimeException('Unable to allocate temporary merged PDF output file.');
}
@unlink($tmpBase);
$outputPdfPath = $tmpBase . '.pdf';
$cmd = escapeshellarg($ghostscriptBinary)
. ' -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -dCompatibilityLevel=1.4'
. ' -sOutputFile=' . escapeshellarg($outputPdfPath);
foreach ($pdfPaths as $path) {
$cmd .= ' ' . escapeshellarg($path);
}
$cmd .= ' 2>&1';
$output = [];
$code = 0;
@exec($cmd, $output, $code);
if ($code !== 0) {
@unlink($outputPdfPath);
throw new \RuntimeException(
'Ghostscript merge failed' . ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.')
);
}
$mergedBytes = @file_get_contents($outputPdfPath);
@unlink($outputPdfPath);
if (!is_string($mergedBytes) || $mergedBytes === '') {
throw new \RuntimeException('Ghostscript merge produced an empty output file.');
}
return $mergedBytes;
}
/** @param array<string,mixed> $mailshot @param array<string,mixed> $row */
private function pdfFilename(array $mailshot, array $row): string
{
$base = '';
$field = trim((string) ($mailshot['PDFFilenameDerivedFrom'] ?? ''));
if ($field !== '') {
foreach ($this->rowFieldCandidates($field) as $candidate) {
foreach ($row as $k => $v) {
if (strcasecmp((string) $k, $candidate) !== 0) {
continue;
}
$base = trim((string) $v);
if ($base !== '') {
break 2;
}
}
}
}
if ($base === '') {
$base = 'mailshot_attachment_' . gmdate('Ymd_His');
}
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', $base) ?? '';
$base = trim($base, '._-');
if ($base === '') {
$base = 'mailshot_attachment_' . gmdate('Ymd_His');
}
if (!str_ends_with(strtolower($base), '.pdf')) {
$base .= '.pdf';
}
return $base;
}
/** @return list<string> */
private function rowFieldCandidates(string $field): array
{
$out = [$field];
if (str_contains($field, '.')) {
$parts = explode('.', $field);
$leaf = trim((string) end($parts));
if ($leaf !== '') {
$out[] = $leaf;
}
}
return array_values(array_unique(array_filter($out, static fn(string $s): bool => $s !== '')));
}
/**
* @param array<string,int> $nameCounts
*/
private function uniqueFilename(string $baseName, array &$nameCounts): string
{
$key = strtolower($baseName);
if (!isset($nameCounts[$key])) {
$nameCounts[$key] = 1;
return $baseName;
}
$nameCounts[$key]++;
$count = $nameCounts[$key];
$ext = pathinfo($baseName, PATHINFO_EXTENSION);
$stem = pathinfo($baseName, PATHINFO_FILENAME);
if ($stem === '') {
$stem = 'attachment';
}
if ($ext === '') {
return $stem . '_' . $count;
}
return $stem . '_' . $count . '.' . $ext;
}
/**
* @param list<string> $paths
*/
private function cleanupMergedPartFiles(string $tmpDir, array $paths): void
{
foreach ($paths as $p) {
if (is_string($p) && $p !== '') {
@unlink($p);
}
}
if ($tmpDir !== '') {
@rmdir($tmpDir);
}
}
/** @param array<string,mixed> $context */
private function downloadDebugLog(string $event, array $context = []): void
{
$line = '[feca-mailshots/run-service][' . gmdate('Y-m-d H:i:s') . ' UTC] ' . $event;
if ($context !== []) {
$json = json_encode($context);
if (is_string($json) && $json !== '') {
$line .= ' ' . $json;
}
}
error_log($line);
if (!defined('ABSPATH')) {
return;
}
$path = ABSPATH . 'wp-content/uploads/feca_mailshots_download_debug.log';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (is_dir($dir) && is_writable($dir)) {
@error_log($line . PHP_EOL, 3, $path);
}
}
/**
* @param array<string,mixed> $mailshot
* @return list<array{filename:string,mime_type:string,content_bytes:string}>
*/
private function staticAttachments(array $mailshot): array
{
$raw = (string) ($mailshot['AttachmentNames'] ?? '[]');
$names = [];
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
foreach ($decoded as $item) {
$name = trim((string) $item);
if ($name !== '') {
$names[] = $name;
}
}
}
$names = array_values(array_unique($names));
if ($names === []) {
return [];
}
$out = [];
foreach ($names as $name) {
$asset = $this->attachments->findByName($name);
if (!is_array($asset)) {
throw new \RuntimeException('Attachment "' . $name . '" not found.');
}
$fileName = trim((string) ($asset['file_name'] ?? ''));
$mimeType = trim((string) ($asset['mime_type'] ?? ''));
if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename($fileName);
}
$bytes = (string) ($asset['file_bytes'] ?? '');
if ($fileName === '' || $mimeType === '' || $bytes === '') {
throw new \RuntimeException(
'Attachment "' . $name . '" is invalid: file_name=' . ($fileName === '' ? 'missing' : 'ok')
. ', mime_type=' . ($mimeType === '' ? 'missing' : 'ok')
. ', file_bytes=' . ($bytes === '' ? 'empty' : ('len ' . strlen($bytes)))
);
}
$out[] = [
'filename' => $fileName,
'mime_type' => $mimeType,
'content_bytes' => $bytes,
];
}
return $out;
}
private function inferMimeTypeFromFilename(string $fileName): string
{
$fileName = trim($fileName);
if ($fileName === '') {
return '';
}
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '') {
return '';
}
$map = [
'txt' => 'text/plain',
'csv' => 'text/csv',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'odt' => 'application/vnd.oasis.opendocument.text',
'rtf' => 'application/rtf',
'html' => 'text/html',
'htm' => 'text/html',
'json' => 'application/json',
'xml' => 'application/xml',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'zip' => 'application/zip',
];
return $map[$ext] ?? '';
}
}

View File

@ -68,12 +68,7 @@ final class MailshotService
if ($subject === '') {
$errors[] = 'Subject is required.';
}
if ($message === '') {
$errors[] = 'Message is required.';
}
if ($recipientEmailField === '') {
$errors[] = 'RecipientEmailField is required.';
} elseif ($dataSource !== '') {
if ($recipientEmailField !== '' && $dataSource !== '') {
$tokens = $this->tokenInsertionData($dataSource);
$fields = [];
foreach (($tokens['tokens'] ?? []) as $token) {

View File

@ -26,7 +26,10 @@ final class PdfAssetService
{
$name = trim((string) ($payload['name'] ?? ''));
$fileName = trim((string) ($payload['file_name'] ?? ''));
$mimeType = trim((string) ($payload['mime_type'] ?? 'application/octet-stream'));
$mimeType = trim((string) ($payload['mime_type'] ?? ''));
if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename($fileName);
}
$justification = trim((string) ($payload['justification'] ?? 'in-place'));
$width = (float) ($payload['width_mm'] ?? 0);
$height = (float) ($payload['height_mm'] ?? 0);
@ -39,6 +42,9 @@ final class PdfAssetService
if ($fileName === '') {
$errors[] = 'file_name is required.';
}
if ($mimeType === '') {
$errors[] = 'mime_type is required (or inferable from file_name).';
}
if ($width <= 0 || $height <= 0) {
$errors[] = 'width_mm and height_mm must be > 0.';
}
@ -78,4 +84,28 @@ final class PdfAssetService
{
$this->repo->delete($id);
}
private function inferMimeTypeFromFilename(string $fileName): string
{
$fileName = trim($fileName);
if ($fileName === '') {
return '';
}
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '') {
return '';
}
$map = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'bmp' => 'image/bmp',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
];
return $map[$ext] ?? '';
}
}

View File

@ -11,7 +11,8 @@ interface SmtpSender
* @param list<string> $to
* @param list<string> $cc
* @param list<string> $bcc
* @param list<array{filename:string,mime_type:string,content_bytes:string}> $attachments
* @return array{raw_mime:string}
*/
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array;
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array;
}

View File

@ -6,6 +6,15 @@ namespace FecaMailshots\Application;
final class TemplateRenderer
{
/** @var null|callable(string):?array<string,mixed> */
private $pdfAssetResolver;
/** @param null|callable(string):?array<string,mixed> $pdfAssetResolver */
public function __construct(?callable $pdfAssetResolver = null)
{
$this->pdfAssetResolver = $pdfAssetResolver;
}
/**
* @param array<string, mixed> $context
* @return array{subject:string,message:string,pdf_attachment:string,warnings:list<string>}
@ -23,33 +32,194 @@ final class TemplateRenderer
]);
$twig = new \Twig\Environment($loader, [
'autoescape' => 'html',
'strict_variables' => false,
'strict_variables' => true,
'cache' => false,
]);
$twig->addFunction(new \Twig\TwigFunction(
'pdf_asset',
function (string $name): string {
return $this->renderPdfAsset($name);
},
['is_safe' => ['html']]
));
// Provide both original keys and canonicalized token keys.
$safeContext = $this->canonicalizeContext($context);
return [
'subject' => (string) $twig->render('subject', $safeContext),
'subject' => html_entity_decode((string) $twig->render('subject', $safeContext), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'message' => (string) $twig->render('message', $safeContext),
'pdf_attachment' => (string) $twig->render('pdf', $safeContext),
'warnings' => [],
];
}
private function renderPdfAsset(string $name): string
{
$name = trim($name);
if ($name === '') {
throw new \RuntimeException('pdf_asset() requires a non-empty asset name.');
}
if (!is_callable($this->pdfAssetResolver)) {
throw new \RuntimeException('pdf_asset() is unavailable: PDF asset resolver is not configured.');
}
$asset = ($this->pdfAssetResolver)($name);
if (!is_array($asset)) {
throw new \RuntimeException('pdf_asset("' . $name . '") not found.');
}
$mimeType = trim((string) ($asset['mime_type'] ?? ''));
$bytes = (string) ($asset['file_bytes'] ?? '');
$widthMm = (float) ($asset['width_mm'] ?? 0);
$heightMm = (float) ($asset['height_mm'] ?? 0);
$justification = trim((string) ($asset['justification'] ?? ''));
$fileName = trim((string) ($asset['file_name'] ?? ''));
if ($mimeType === '' || $bytes === '') {
throw new \RuntimeException('pdf_asset("' . $name . '") is missing file content.');
}
if ($widthMm <= 0 || $heightMm <= 0) {
throw new \RuntimeException('pdf_asset("' . $name . '") has invalid width/height.');
}
if (!in_array($justification, ['in-place', 'left', 'right'], true)) {
throw new \RuntimeException('pdf_asset("' . $name . '") has invalid justification value.');
}
if ($this->needsPngTranscodeForRuntime($mimeType)) {
[$mimeType, $bytes] = $this->transcodePngToJpeg($name, $bytes);
}
$dataUri = 'data:' . $mimeType . ';base64,' . base64_encode($bytes);
$imgStyle = 'width:' . $this->mm($widthMm) . ';height:' . $this->mm($heightMm) . ';';
$alt = htmlspecialchars($fileName !== '' ? $fileName : $name, ENT_QUOTES);
$src = htmlspecialchars($dataUri, ENT_QUOTES);
$img = '<img src="' . $src . '" alt="' . $alt . '" style="' . $imgStyle . '">';
if ($justification === 'in-place') {
return $img;
}
return '<div style="display:block;text-align:' . $justification . ';">' . $img . '</div>';
}
/** @param array<string, mixed> $row @return array<string, mixed> */
private function canonicalizeContext(array $row): array
{
$out = $row;
foreach ($row as $key => $value) {
$lower = strtolower((string) $key);
$canon = preg_replace('/[^a-z0-9_]+/', '_', $lower) ?? $lower;
$canon = trim((string) preg_replace('/_+/', '_', $canon), '_');
$canon = preg_replace('/[^a-z0-9_]+/', '_', $lower);
if (!is_string($canon)) {
throw new \RuntimeException('Failed to canonicalize template context key.');
}
$canon = preg_replace('/_+/', '_', $canon);
if (!is_string($canon)) {
throw new \RuntimeException('Failed to canonicalize template context key.');
}
$canon = trim($canon, '_');
if ($canon !== '' && !array_key_exists($canon, $out)) {
$out[$canon] = $value;
}
}
return $out;
}
private function mm(float $value): string
{
$formatted = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
if ($formatted === '') {
$formatted = '0';
}
return $formatted . 'mm';
}
private function needsPngTranscodeForRuntime(string $mimeType): bool
{
return strtolower($mimeType) === 'image/png' && !function_exists('imagecreatefrompng');
}
/**
* @return array{0:string,1:string}
*/
private function transcodePngToJpeg(string $assetName, string $pngBytes): array
{
$convertBinary = $this->findExecutableBinary('convert', ['/usr/bin/convert', '/bin/convert']);
if ($convertBinary === '') {
throw new \RuntimeException(
'pdf_asset("' . $assetName . '") uses PNG, but this runtime lacks GD and ImageMagick convert.'
);
}
if (!function_exists('exec')) {
throw new \RuntimeException(
'pdf_asset("' . $assetName . '") cannot transcode PNG: exec() is unavailable in this PHP runtime.'
);
}
$inputPath = tempnam(sys_get_temp_dir(), 'feca_pdf_asset_png_');
if (!is_string($inputPath) || $inputPath === '') {
throw new \RuntimeException('Unable to allocate temporary file for PNG transcode.');
}
$inputPngPath = $inputPath . '.png';
if (!@rename($inputPath, $inputPngPath)) {
@unlink($inputPath);
throw new \RuntimeException('Unable to prepare temporary PNG file for transcode.');
}
$outputJpegPath = $inputPngPath . '.jpg';
try {
if (@file_put_contents($inputPngPath, $pngBytes) === false) {
throw new \RuntimeException('Unable to write temporary PNG for transcode.');
}
$cmd = escapeshellarg($convertBinary) . ' '
. escapeshellarg($inputPngPath)
. ' -background white -alpha remove -alpha off -quality 90 '
. escapeshellarg($outputJpegPath)
. ' 2>&1';
$output = [];
$code = 0;
@exec($cmd, $output, $code);
if ($code !== 0 || !is_file($outputJpegPath)) {
throw new \RuntimeException(
'ImageMagick convert failed for png asset "' . $assetName . '"'
. ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.')
);
}
$jpegBytes = @file_get_contents($outputJpegPath);
if (!is_string($jpegBytes) || $jpegBytes === '') {
throw new \RuntimeException('Converted JPEG is empty for png asset "' . $assetName . '".');
}
return ['image/jpeg', $jpegBytes];
} finally {
@unlink($inputPngPath);
@unlink($outputJpegPath);
}
}
private function findExecutableBinary(string $binaryName, array $fallbackPaths = []): string
{
$pathEnv = (string) getenv('PATH');
$candidates = [];
if ($pathEnv !== '') {
foreach (explode(':', $pathEnv) as $dir) {
$dir = trim($dir);
if ($dir !== '') {
$candidates[] = rtrim($dir, '/') . '/' . $binaryName;
}
}
}
foreach ($fallbackPaths as $p) {
$candidates[] = $p;
}
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '' && is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
}
return '';
}
}

View File

@ -8,7 +8,7 @@ use FecaMailshots\Application\SmtpSender;
final class BasicSmtpSender implements SmtpSender
{
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array
{
$host = (string) ($credentials['smtp_host'] ?? '');
$port = (int) ($credentials['smtp_port'] ?? 0);
@ -53,8 +53,9 @@ final class BasicSmtpSender implements SmtpSender
$this->cmd($fp, 'DATA', [354]);
$raw = $this->buildMime($from, $fromName, $to, $cc, $bcc, $subject, $htmlBody, $replyTo);
fwrite($fp, $raw . "\r\n.\r\n");
$raw = $this->buildMime($from, $fromName, $to, $cc, $bcc, $subject, $htmlBody, $replyTo, $attachments);
fwrite($fp, $raw);
fwrite($fp, "\r\n.\r\n");
$this->expect($fp, [250]);
$this->cmd($fp, 'QUIT', [221], false);
@ -65,7 +66,7 @@ final class BasicSmtpSender implements SmtpSender
}
/** @param list<string> $to @param list<string> $cc @param list<string> $bcc */
private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo): string
private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo, array $attachments): string
{
$headers = [];
$fromHeader = $fromName !== '' ? sprintf('%s <%s>', $fromName, $from) : $from;
@ -82,11 +83,70 @@ final class BasicSmtpSender implements SmtpSender
}
$headers[] = 'Subject: ' . $subject;
$headers[] = 'MIME-Version: 1.0';
if ($attachments === []) {
$headers[] = 'Content-Type: text/html; charset=UTF-8';
return implode("\r\n", $headers) . "\r\n\r\n" . $htmlBody;
}
$boundary = 'feca_mailshots_' . bin2hex(random_bytes(12));
$mime = implode("\r\n", array_merge($headers, ['Content-Type: multipart/mixed; boundary="' . $boundary . '"'])) . "\r\n\r\n";
$mime .= '--' . $boundary . "\r\n";
$mime .= 'Content-Type: text/html; charset=UTF-8' . "\r\n";
$mime .= 'Content-Transfer-Encoding: 8bit' . "\r\n\r\n";
$mime .= $htmlBody . "\r\n";
foreach ($attachments as $attachment) {
$filename = trim((string) ($attachment['filename'] ?? 'attachment.bin'));
$mimeType = trim((string) ($attachment['mime_type'] ?? 'application/octet-stream'));
$bytes = (string) ($attachment['content_bytes'] ?? '');
if ($filename === '' || $bytes === '') {
continue;
}
$escapedFilename = addcslashes($filename, '"\\');
$mime .= '--' . $boundary . "\r\n";
$mime .= 'Content-Type: ' . $mimeType . '; name="' . $escapedFilename . '"' . "\r\n";
$mime .= 'Content-Transfer-Encoding: base64' . "\r\n";
$mime .= 'Content-Disposition: attachment; filename="' . $escapedFilename . '"' . "\r\n\r\n";
$mime .= $this->encodeBase64Chunked($bytes) . "\r\n";
}
$mime .= '--' . $boundary . '--' . "\r\n";
return $mime;
}
private function encodeBase64Chunked(string $bytes): string
{
$stream = fopen('php://temp', 'w+b');
if (!is_resource($stream)) {
throw new \RuntimeException('Unable to allocate temp stream for attachment encoding.');
}
try {
if (fwrite($stream, $bytes) === false) {
throw new \RuntimeException('Failed writing attachment bytes to temp stream.');
}
rewind($stream);
$filter = stream_filter_append(
$stream,
'convert.base64-encode',
STREAM_FILTER_READ,
['line-length' => 76, 'line-break-chars' => "\r\n"]
);
if ($filter === false) {
throw new \RuntimeException('Failed to initialize base64 stream filter.');
}
$encoded = stream_get_contents($stream);
if (!is_string($encoded)) {
throw new \RuntimeException('Failed to read encoded attachment bytes.');
}
return rtrim($encoded, "\r\n");
} finally {
fclose($stream);
}
}
/** @param list<int> $codes */
private function cmd($fp, string $cmd, array $codes, bool $throwOnMismatch = true): ?string
{

View File

@ -12,6 +12,8 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
/** @var array<string, list<string>> */
private array $builtInFields;
/** @var array<string, list<string>> */
private array $sourceFieldsCache = [];
/** @var array<string, array{left:string,right:string}> */
private array $joinMap;
@ -58,7 +60,15 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
public function sourceFields(string $source): array
{
if (isset($this->builtInFields[$source])) {
return $this->builtInFields[$source];
$fields = $this->loadFieldsFromTable($source);
if ($source === 'accounts') {
foreach (['type', 'public_location', 'account_sector'] as $virtualField) {
if (!in_array($virtualField, $fields, true)) {
$fields[] = $virtualField;
}
}
}
return $fields;
}
if (!str_contains($source, '.')) {
@ -87,4 +97,28 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
{
return ['contacts', 'accounts', 'renewals', 'grants'];
}
/** @return list<string> */
private function loadFieldsFromTable(string $table): array
{
$table = trim($table);
if ($table === '') {
return [];
}
if (isset($this->sourceFieldsCache[$table])) {
return $this->sourceFieldsCache[$table];
}
$sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $table) . '`';
$rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
$fields = array_values(array_filter(
array_map(static fn(array $row): string => trim((string) ($row['Field'] ?? '')), $rows),
static fn(string $v): bool => $v !== ''
));
if ($fields === []) {
throw new \RuntimeException('Source "' . $table . '" has no readable columns.');
}
$this->sourceFieldsCache[$table] = $fields;
return $fields;
}
}

View File

@ -18,13 +18,13 @@ final class PhpImapAppender implements ImapAppender
}
$host = (string) ($credentials['imap_host'] ?? '');
$port = (int) ($credentials['imap_port'] ?? 993);
$port = (int) ($credentials['imap_port'] ?? 0);
$user = (string) ($credentials['imap_user'] ?? '');
$pass = (string) ($credentials['imap_password'] ?? '');
$folder = (string) ($credentials['imap_sent_folder'] ?? 'Sent');
$flags = (string) ($credentials['imap_mailbox_flags'] ?? '/imap/ssl');
$folder = (string) ($credentials['imap_sent_folder'] ?? '');
$flags = (string) ($credentials['imap_mailbox_flags'] ?? '');
if ($host === '' || $user === '' || $pass === '') {
if ($host === '' || $port <= 0 || $user === '' || $pass === '' || $folder === '' || $flags === '') {
throw new \RuntimeException('IMAP credentials are missing.');
}
@ -32,14 +32,15 @@ final class PhpImapAppender implements ImapAppender
throw new \RuntimeException('IMAP extension is not available in PHP runtime.');
}
$mailbox = sprintf('{%s:%d%s}%s', $host, $port, $flags, $folder);
$imap = @imap_open($mailbox, $user, $pass);
$mailboxRoot = sprintf('{%s:%d%s}', $host, $port, $flags);
$imap = @imap_open($mailboxRoot . 'INBOX', $user, $pass);
if ($imap === false) {
throw new \RuntimeException('Failed to open IMAP mailbox: ' . (imap_last_error() ?: 'unknown error'));
}
try {
if (!@imap_append($imap, $mailbox, $rawMime . "\r\n", "\\Seen")) {
$appendMailbox = $this->resolveAppendMailbox($imap, $mailboxRoot, $folder);
if (!@imap_append($imap, $appendMailbox, $rawMime . "\r\n", "\\Seen")) {
throw new \RuntimeException('Failed to append to IMAP Sent folder: ' . (imap_last_error() ?: 'unknown error'));
}
$this->attemptCache[$attemptId] = true;
@ -47,4 +48,99 @@ final class PhpImapAppender implements ImapAppender
imap_close($imap);
}
}
/**
* @param resource $imap
*/
private function resolveAppendMailbox($imap, string $mailboxRoot, string $configuredFolder): string
{
$configuredFolder = trim($configuredFolder);
if ($configuredFolder === '') {
throw new \RuntimeException('IMAP sent folder is required.');
}
$exact = $mailboxRoot . $configuredFolder;
$boxes = function_exists('imap_getmailboxes') ? @imap_getmailboxes($imap, $mailboxRoot, '*') : false;
if (!is_array($boxes) || $boxes === []) {
return $exact;
}
$names = [];
foreach ($boxes as $box) {
$name = (string) ($box->name ?? '');
if ($name !== '') {
$names[] = $name;
}
}
if ($names === []) {
return $exact;
}
foreach ($names as $name) {
if (strcasecmp($name, $exact) === 0) {
return $name;
}
}
$matches = [];
$configuredLower = strtolower($configuredFolder);
foreach ($names as $name) {
if (str_starts_with($name, $mailboxRoot)) {
$short = substr($name, strlen($mailboxRoot));
} else {
$short = $name;
}
if (strtolower($short) === $configuredLower) {
$matches[] = $name;
continue;
}
$terminal = $this->terminalMailboxName($short);
if ($terminal !== '' && strtolower($terminal) === $configuredLower) {
$matches[] = $name;
}
}
if (count($matches) === 1) {
return $matches[0];
}
if (count($matches) > 1) {
throw new \RuntimeException(
'Configured IMAP sent folder is ambiguous. Matches: ' . implode(', ', $matches)
);
}
$inboxCandidates = [
$mailboxRoot . 'INBOX.' . $configuredFolder,
$mailboxRoot . 'INBOX/' . $configuredFolder,
];
foreach ($inboxCandidates as $candidate) {
foreach ($names as $name) {
if (strcasecmp($name, $candidate) === 0) {
return $name;
}
}
}
throw new \RuntimeException(
'IMAP sent folder "' . $configuredFolder . '" does not exist on server. Available mailboxes include: '
. implode(', ', array_slice($names, 0, 20))
);
}
private function terminalMailboxName(string $name): string
{
$name = trim($name);
if ($name === '') {
return '';
}
$dotPos = strrpos($name, '.');
$slashPos = strrpos($name, '/');
$pos = max($dotPos === false ? -1 : $dotPos, $slashPos === false ? -1 : $slashPos);
if ($pos < 0) {
return $name;
}
return substr($name, $pos + 1);
}
}

View File

@ -10,7 +10,9 @@ use FecaMailshots\Admin\MailshotsAdminPage;
use FecaMailshots\Admin\MailshotTestAdminPage;
use FecaMailshots\Admin\PdfAssetsAdminPage;
use FecaMailshots\Admin\ProfileAdminPage;
use FecaMailshots\Admin\ReviewRecipientsAdminPage;
use FecaMailshots\Admin\RunMailshotAdminPage;
use FecaMailshots\Admin\DownloadPdfAdminPage;
use FecaMailshots\Admin\SetupAdminPage;
use FecaMailshots\Application\AttachmentService;
use FecaMailshots\Application\DataSourceService;
@ -89,7 +91,11 @@ final class Plugin
$c->set(AttachmentService::class, static fn(Container $c) => new AttachmentService($c->get(AttachmentRepository::class)));
$c->set(PdfAssetService::class, static fn(Container $c) => new PdfAssetService($c->get(PdfAssetRepository::class)));
$c->set(TemplateRenderer::class, static fn() => new TemplateRenderer());
$c->set(TemplateRenderer::class, static fn(Container $c) => new TemplateRenderer(
static function (string $name) use ($c): ?array {
return $c->get(PdfAssetRepository::class)->findByName($name);
}
));
$c->set(SmtpSender::class, static fn() => new BasicSmtpSender());
$c->set(ImapAppender::class, static fn() => new PhpImapAppender());
$c->set(MailCredentialsProvider::class, static fn(Container $c) => new PerUserMailCredentialsProvider(
@ -99,6 +105,7 @@ final class Plugin
$c->set(MailshotRunService::class, static fn(Container $c) => new MailshotRunService(
$c->get(MailshotRepository::class),
$c->get(MailshotQueryRepository::class),
$c->get(AttachmentRepository::class),
$c->get(DataSourceService::class),
$c->get(TemplateRenderer::class),
$c->get(SmtpSender::class),
@ -142,6 +149,15 @@ final class Plugin
static fn(): MailshotService => $c->get(MailshotService::class),
$wp
));
$c->set(ReviewRecipientsAdminPage::class, static fn(Container $c) => new ReviewRecipientsAdminPage(
static fn(): DataSourceService => $c->get(DataSourceService::class),
$wp
));
$c->set(DownloadPdfAdminPage::class, static fn(Container $c) => new DownloadPdfAdminPage(
static fn(): MailshotRunService => $c->get(MailshotRunService::class),
static fn(): MailshotService => $c->get(MailshotService::class),
$wp
));
return $c;
}

View File

@ -61,4 +61,88 @@ final class AttachmentRepository
$rows = $this->router->mailshotsPdo()->query('SELECT name FROM mailshot_attachments ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC);
return array_map(static fn(array $r): string => (string) $r['name'], $rows);
}
/** @return array<string,mixed>|null */
public function findByName(string $name): ?array
{
$name = trim($name);
if ($name === '') {
return null;
}
$sql = 'SELECT id, name, file_name, mime_type, TO_BASE64(file_bytes) AS file_bytes_b64, created_at, updated_at
FROM mailshot_attachments
WHERE LOWER(name) = LOWER(:name)
LIMIT 1';
$stmt = $this->router->mailshotsPdo()->prepare($sql);
$stmt->execute(['name' => $name]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
$row['file_bytes'] = $this->decodeBase64Blob((string) ($row['file_bytes_b64'] ?? ''));
unset($row['file_bytes_b64']);
$mimeType = trim((string) ($row['mime_type'] ?? ''));
if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename((string) ($row['file_name'] ?? ''));
if ($mimeType !== '') {
$row['mime_type'] = $mimeType;
$this->updateMimeType((int) ($row['id'] ?? 0), $mimeType);
}
}
return $row;
}
private function decodeBase64Blob(string $value): string
{
if ($value === '') {
return '';
}
$decoded = base64_decode($value, true);
if (!is_string($decoded)) {
throw new \RuntimeException('Failed to decode attachment blob.');
}
return $decoded;
}
private function updateMimeType(int $id, string $mimeType): void
{
if ($id <= 0 || trim($mimeType) === '') {
return;
}
$stmt = $this->router->mailshotsPdo()->prepare('UPDATE mailshot_attachments SET mime_type = :mime WHERE id = :id');
$stmt->execute(['mime' => $mimeType, 'id' => $id]);
}
private function inferMimeTypeFromFilename(string $fileName): string
{
$fileName = trim($fileName);
if ($fileName === '') {
return '';
}
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext === '') {
return '';
}
$map = [
'txt' => 'text/plain',
'csv' => 'text/csv',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'odt' => 'application/vnd.oasis.opendocument.text',
'rtf' => 'application/rtf',
'html' => 'text/html',
'htm' => 'text/html',
'json' => 'application/json',
'xml' => 'application/xml',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'zip' => 'application/zip',
];
return $map[$ext] ?? '';
}
}

View File

@ -38,15 +38,15 @@ final class MailCredentialRepository
'smtp_user' => (string) $row['smtp_user'],
'smtp_password' => $this->decrypt((string) $row['smtp_password_enc']),
'smtp_from_email' => (string) $row['smtp_from_email'],
'smtp_from_name' => (string) ($row['smtp_from_name'] ?? ''),
'smtp_require_tls' => (int) ($row['smtp_require_tls'] ?? 0) === 1,
'imap_host' => (string) ($row['imap_host'] ?? ''),
'imap_port' => (int) ($row['imap_port'] ?? 993),
'imap_user' => (string) ($row['imap_user'] ?? ''),
'imap_password' => $this->decrypt((string) ($row['imap_password_enc'] ?? '')),
'imap_sent_folder' => (string) ($row['imap_sent_folder'] ?? 'Sent'),
'imap_mailbox_flags' => (string) ($row['imap_mailbox_flags'] ?? '/imap/ssl'),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'smtp_from_name' => (string) $row['smtp_from_name'],
'smtp_require_tls' => (int) $row['smtp_require_tls'] === 1,
'imap_host' => (string) $row['imap_host'],
'imap_port' => (int) $row['imap_port'],
'imap_user' => (string) $row['imap_user'],
'imap_password' => $this->decrypt((string) $row['imap_password_enc']),
'imap_sent_folder' => (string) $row['imap_sent_folder'],
'imap_mailbox_flags' => (string) $row['imap_mailbox_flags'],
'updated_at' => (string) $row['updated_at'],
];
}
@ -55,16 +55,28 @@ final class MailCredentialRepository
{
$this->ensureTable();
$existing = $this->findByUserId($userId);
$smtpPassword = (string) ($payload['smtp_password'] ?? '');
$imapPassword = (string) ($payload['imap_password'] ?? '');
if ($smtpPassword === '' && is_array($existing)) {
$smtpPassword = (string) ($existing['smtp_password'] ?? '');
foreach ([
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'smtp_from_email',
'smtp_from_name',
'imap_host',
'imap_port',
'imap_user',
'imap_password',
'imap_sent_folder',
'imap_mailbox_flags',
] as $required) {
if (!array_key_exists($required, $payload)) {
throw new \RuntimeException('Missing required credential field: ' . $required);
}
if ($imapPassword === '' && is_array($existing)) {
$imapPassword = (string) ($existing['imap_password'] ?? '');
}
$smtpPassword = trim((string) $payload['smtp_password']);
$imapPassword = trim((string) $payload['imap_password']);
if ($smtpPassword === '' || $imapPassword === '') {
throw new \RuntimeException('SMTP and IMAP passwords are required.');
}
$sql = 'INSERT INTO mailshot_credentials (wp_user_id, smtp_host, smtp_port, smtp_user, smtp_password_enc, smtp_from_email, smtp_from_name, smtp_require_tls, imap_host, imap_port, imap_user, imap_password_enc, imap_sent_folder, imap_mailbox_flags)
@ -88,19 +100,19 @@ final class MailCredentialRepository
$stmt = $this->router->mailshotsPdo()->prepare($sql);
$stmt->execute([
'wp_user_id' => $userId,
'smtp_host' => trim((string) ($payload['smtp_host'] ?? '')),
'smtp_port' => (int) ($payload['smtp_port'] ?? 587),
'smtp_user' => trim((string) ($payload['smtp_user'] ?? '')),
'smtp_host' => trim((string) $payload['smtp_host']),
'smtp_port' => (int) $payload['smtp_port'],
'smtp_user' => trim((string) $payload['smtp_user']),
'smtp_password_enc' => $this->encrypt($smtpPassword),
'smtp_from_email' => trim((string) ($payload['smtp_from_email'] ?? '')),
'smtp_from_name' => trim((string) ($payload['smtp_from_name'] ?? '')),
'smtp_from_email' => trim((string) $payload['smtp_from_email']),
'smtp_from_name' => trim((string) $payload['smtp_from_name']),
'smtp_require_tls' => !empty($payload['smtp_require_tls']) ? 1 : 0,
'imap_host' => trim((string) ($payload['imap_host'] ?? '')),
'imap_port' => (int) ($payload['imap_port'] ?? 993),
'imap_user' => trim((string) ($payload['imap_user'] ?? '')),
'imap_host' => trim((string) $payload['imap_host']),
'imap_port' => (int) $payload['imap_port'],
'imap_user' => trim((string) $payload['imap_user']),
'imap_password_enc' => $this->encrypt($imapPassword),
'imap_sent_folder' => trim((string) ($payload['imap_sent_folder'] ?? 'Sent')),
'imap_mailbox_flags' => trim((string) ($payload['imap_mailbox_flags'] ?? '/imap/ssl')),
'imap_sent_folder' => trim((string) $payload['imap_sent_folder']),
'imap_mailbox_flags' => trim((string) $payload['imap_mailbox_flags']),
]);
}

View File

@ -23,6 +23,28 @@ final class PdfAssetRepository
return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
/** @return array<string,mixed>|null */
public function findByName(string $name): ?array
{
$name = trim($name);
if ($name === '') {
return null;
}
$sql = 'SELECT id, name, file_name, mime_type, TO_BASE64(file_bytes) AS file_bytes_b64, width_mm, height_mm, justification
FROM mailshot_pdf_assets
WHERE LOWER(name) = LOWER(:name)
LIMIT 1';
$stmt = $this->router->mailshotsPdo()->prepare($sql);
$stmt->execute(['name' => $name]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
$row['file_bytes'] = $this->decodeBase64Blob((string) ($row['file_bytes_b64'] ?? ''));
unset($row['file_bytes_b64']);
return $row;
}
/** @param array<string, mixed> $row */
public function create(array $row): int
{
@ -60,4 +82,16 @@ final class PdfAssetRepository
$stmt = $this->router->mailshotsPdo()->prepare('DELETE FROM mailshot_pdf_assets WHERE id = :id');
$stmt->execute(['id' => $id]);
}
private function decodeBase64Blob(string $value): string
{
if ($value === '') {
return '';
}
$decoded = base64_decode($value, true);
if (!is_string($decoded)) {
throw new \RuntimeException('Failed to decode PDF asset blob.');
}
return $decoded;
}
}

View File

@ -10,7 +10,18 @@ final class FixtureWordPressFacade implements WordPressFacade
private array $actions = [];
/** @var array<string, mixed> */
private array $options = [];
private string $optionsFile;
private int $currentUserId = 1;
/** @var array<string,bool>|null */
private ?array $allowedCapabilities = null;
public function __construct(?string $optionsFile = null)
{
$this->optionsFile = $optionsFile !== null && $optionsFile !== ''
? $optionsFile
: (sys_get_temp_dir() . '/feca_mailshots_fixture_options.json');
$this->options = $this->loadOptions();
}
public function addAction(string $hook, callable $callback): void
{
@ -32,14 +43,22 @@ final class FixtureWordPressFacade implements WordPressFacade
public function currentUserCan(string $capability): bool
{
if ($this->allowedCapabilities === null) {
return true;
}
return isset($this->allowedCapabilities[$capability]);
}
public function verifyNonce(string $nonce, string $action): bool
{
return $nonce !== '';
}
public function createNonce(string $action): string
{
return 'fixture-nonce-' . $action;
}
public function requestParam(string $name, ?string $default = null): ?string
{
if (isset($_POST[$name])) {
@ -70,18 +89,23 @@ final class FixtureWordPressFacade implements WordPressFacade
public function getOption(string $name, $default = null)
{
$this->options = $this->loadOptions();
return $this->options[$name] ?? $default;
}
public function updateOption(string $name, $value): bool
{
$this->options = $this->loadOptions();
$this->options[$name] = $value;
$this->saveOptions();
return true;
}
public function deleteOption(string $name): bool
{
$this->options = $this->loadOptions();
unset($this->options[$name]);
$this->saveOptions();
return true;
}
@ -96,4 +120,42 @@ final class FixtureWordPressFacade implements WordPressFacade
{
$this->currentUserId = max(1, $userId);
}
/** @param list<string> $capabilities */
public function setAllowedCapabilities(array $capabilities): void
{
$map = [];
foreach ($capabilities as $capability) {
$key = trim((string) $capability);
if ($key === '') {
continue;
}
$map[$key] = true;
}
$this->allowedCapabilities = $map;
}
public function clearAllowedCapabilities(): void
{
$this->allowedCapabilities = null;
}
/** @return array<string,mixed> */
private function loadOptions(): array
{
if (!is_file($this->optionsFile)) {
return [];
}
$raw = @file_get_contents($this->optionsFile);
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private function saveOptions(): void
{
@file_put_contents($this->optionsFile, json_encode($this->options, JSON_UNESCAPED_SLASHES));
}
}

View File

@ -31,17 +31,31 @@ final class ProductionWordPressFacade implements WordPressFacade
return wp_verify_nonce($nonce, $action) === 1;
}
public function createNonce(string $action): string
{
return (string) wp_create_nonce($action);
}
public function requestParam(string $name, ?string $default = null): ?string
{
if (isset($_POST[$name])) {
return (string) $_POST[$name];
return $this->normalizeRequestValue($_POST[$name]);
}
if (isset($_GET[$name])) {
return (string) $_GET[$name];
return $this->normalizeRequestValue($_GET[$name]);
}
return $default;
}
/** @param mixed $value */
private function normalizeRequestValue($value): string
{
if (!function_exists('wp_unslash')) {
throw new \RuntimeException('WordPress function wp_unslash is unavailable in ProductionWordPressFacade.');
}
return (string) wp_unslash($value);
}
public function sendJson(array $payload, int $statusCode = 200): void
{
status_header($statusCode);

View File

@ -15,6 +15,7 @@ interface WordPressFacade
public function currentUserCan(string $capability): bool;
public function verifyNonce(string $nonce, string $action): bool;
public function createNonce(string $action): string;
public function requestParam(string $name, ?string $default = null): ?string;

View File

@ -9,13 +9,20 @@ require_once __DIR__ . '/autoload.php';
$getOption = static function (string $key): string {
if (!function_exists('get_option')) {
return '';
throw new \RuntimeException('WordPress get_option() is unavailable while bootstrapping mailshots plugin.');
}
$raw = get_option(\FecaMailshots\Admin\SetupAdminPage::OPTION_KEY, []);
if (!is_array($raw) || !isset($raw[$key])) {
return '';
if (!is_array($raw)) {
throw new \RuntimeException('Mailshots setup option is missing or invalid.');
}
return trim((string) $raw[$key]);
if (!array_key_exists($key, $raw)) {
throw new \RuntimeException('Missing setup configuration key: ' . $key);
}
$value = trim((string) $raw[$key]);
if ($value === '') {
throw new \RuntimeException('Empty setup configuration value: ' . $key);
}
return $value;
};
$dbConfig = [
@ -37,3 +44,5 @@ $container->get(FecaMailshots\Admin\SetupAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register();
$container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register();
$container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\DownloadPdfAdminPage::class)->register();

301
formats/renewals_v1.html Normal file
View File

@ -0,0 +1,301 @@
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 9.5pt;
line-height: 1.25;
margin: 0;
padding: 0;
}
.page {
width: 100%;
}
.logo {
margin-bottom: 3mm;
}
.header {
border-bottom: 1.5px solid #d97706;
padding-bottom: 2.5mm;
margin-bottom: 4mm;
}
.title {
font-size: 15pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 1mm 0;
}
.subtitle {
font-size: 9pt;
color: #6b7280;
margin: 0;
}
.fee-banner {
margin: 6mm 0 3mm 0;
padding: 2mm 3mm;
background: #fff7ed;
border-left: 4px solid #d97706;
font-size: 10.5pt;
font-weight: bold;
color: #9a3412;
}
.note {
margin: 0 0 3mm 0;
color: #4b5563;
font-style: italic;
}
.account-name {
margin: 0 0 3mm 0;
font-size: 11.5pt;
font-weight: bold;
color: #111827;
}
.section-title {
font-size: 8.7pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #374151;
background: #f3f4f6;
padding: 1.6mm 2.5mm;
border: 1px solid #d1d5db;
border-bottom: none;
margin-top: 2.5mm;
}
table.details {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin: 0 0 2mm 0;
}
table.details td {
border: 1px solid #d1d5db;
padding: 1.8mm 2.5mm;
vertical-align: top;
}
table.details td.label {
width: 32%;
background: #f9fafb;
font-weight: bold;
color: #374151;
}
table.details td.value {
width: 68%;
color: #111827;
}
.instruction-box {
margin-top: 2.5mm;
padding: 2.5mm 3mm;
background: #f9fafb;
border: 1px solid #d1d5db;
color: #374151;
}
.signature-table {
width: 100%;
border-collapse: collapse;
margin-top: 3mm;
margin-bottom: 3mm;
}
.signature-table td {
width: 50%;
border: 1px solid #d1d5db;
padding: 2.5mm 3mm 2mm 3mm;
vertical-align: bottom;
}
.sig-label {
font-size: 8.5pt;
font-weight: bold;
color: #374151;
margin-bottom: 4mm;
}
.sig-line {
border-bottom: 1px solid #111827;
height: 4mm;
}
.payment-section {
margin-top: 10mm;
padding-top: 2.5mm;
}
.payment-title {
font-size: 9.5pt;
font-weight: bold;
color: #111827;
margin-bottom: 1.5mm;
}
.payment-option {
margin-bottom: 2mm;
}
.small {
font-size: 8.8pt;
color: #4b5563;
}
.strong {
font-weight: bold;
}
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 80pt;
color: rgba(220, 38, 38, 0.15); /* soft transparent red */
font-weight: bold;
letter-spacing: 8px;
z-index: 0;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.page {
position: relative;
z-index: 1;
}
</style>
<div class="watermark">DRAFT</div>
<div class="page">
<div class="logo">
{{ pdf_asset("Fen Edge Logo") }}
</div>
<div class="header">
<div class="title">Membership Renewal 20262027</div>
<div class="subtitle">Fen Edge Community Association</div>
</div>
<div class="fee-banner">Group Membership Fee: £15</div>
<div class="note">Please amend any details that have changed before returning this form.</div>
<div class="account-name">{{ account_name }}</div>
<div class="section-title">Primary Contact Details</div>
<table class="details">
<tbody>
<tr>
<td class="label">Number of members</td>
<td class="value"><strong>{{ account_no_of_members }}</strong></td>
</tr>
<tr>
<td class="label">Contact name</td>
<td class="value"><strong>{{ contact_1_name }}</strong></td>
</tr>
<tr>
<td class="label">Position</td>
<td class="value"><strong>{{ contact_1_position }}</strong></td>
</tr>
<tr>
<td class="label">Address</td>
<td class="value"><strong>{{ contact_1_address_1 }} {{ contact_1_address_2 }} {{ contact_1_town }} {{ contact_1_postcode }}</strong></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><strong>{{ contact_1_email }}</strong></td>
</tr>
<tr>
<td class="label">Phone / Mobile</td>
<td class="value"><strong>{{ contact_1_phone }} {{ contact_1_mobile }}</strong></td>
</tr>
</tbody>
</table>
<div class="section-title">Second Contact</div>
<table class="details">
<tbody>
<tr>
<td class="label">Name</td>
<td class="value"><strong>{{ contact_2_name }}</strong></td>
</tr>
<tr>
<td class="label">Position</td>
<td class="value"><strong>{{ contact_2_position }}</strong></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><strong>{{ contact_2_email }}</strong></td>
</tr>
<tr>
<td class="label">Phone / Mobile</td>
<td class="value"><strong>{{ contact_2_phone }} {{ contact_2_mobile }}</strong></td>
</tr>
</tbody>
</table>
<div class="section-title">Fen Edge News Contact</div>
<table class="details">
<tbody>
<tr>
<td class="label">Name</td>
<td class="value"><strong>{{ fen_contact_name }}</strong></td>
</tr>
<tr>
<td class="label">Email</td>
<td class="value"><strong>{{ fen_contact_email }}</strong></td>
</tr>
</tbody>
</table>
<div class="instruction-box">
Please check your groups public contact details on the FECA website:
<strong>www.fenedge.co.uk/full-members-list</strong> and advise any changes.
</div>
<table class="signature-table">
<tbody>
<tr>
<td>
<div class="sig-label">Signed</div>
<div class="sig-line"><br></div>
</td>
<td>
<div class="sig-label">Date</div>
<div class="sig-line"><br></div>
</td>
</tr>
</tbody>
</table>
<div class="payment-section">
<div class="payment-title">Payment Options</div>
<div class="payment-option">
<span class="strong">1. Cheque:</span>
payable to <strong>Fen Edge Community Association</strong>, sent with this form to
<span class="small"><strong>FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA</strong></span>
</div>
<div class="payment-option">
<span class="strong">2. BACS:</span>
<strong>Sort Code 20-17-22</strong>, <strong>Account No. 40349178</strong>;
then scan and email the form to <strong>info@fenedge.co.uk</strong>
</div>
</div>
</div>

266
formats/renewals_v2.html Normal file
View File

@ -0,0 +1,266 @@
<!-- Template Version: 2 -->
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 10mm; }
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 8.6pt;
line-height: 1.2;
margin: 0;
padding: 0;
}
.page { width: 100%; position: relative; z-index: 1; }
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 70pt;
color: rgba(220, 38, 38, 0.12);
font-weight: bold;
letter-spacing: 7px;
z-index: 0;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.top {
display: table;
width: 100%;
margin-bottom: 2.5mm;
}
.logo { display: table-cell; width: 24%; vertical-align: top; }
.head { display: table-cell; width: 76%; vertical-align: top; padding-left: 3mm; }
.title {
font-size: 14pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 0.8mm 0;
}
.subtitle {
margin: 0;
font-size: 9pt;
color: #4b5563;
}
.fee-banner {
margin: 2.5mm 0 1.8mm 0;
padding: 1.5mm 2.2mm;
background: #fff7ed;
border-left: 3.5px solid #d97706;
font-size: 10pt;
font-weight: bold;
color: #9a3412;
}
.note {
margin: 0 0 2mm 0;
font-style: italic;
color: #374151;
}
.pane {
border: 1px solid #d1d5db;
margin: 0 0 2mm 0;
}
.pane-title {
background: #f3f4f6;
border-bottom: 1px solid #d1d5db;
padding: 1.2mm 2mm;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.4px;
font-size: 8pt;
color: #374151;
}
.pane-body { padding: 1.5mm 2mm; }
.two-col {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.two-col td {
width: 50%;
vertical-align: top;
padding: 0 1.2mm;
}
.two-col td:first-child { padding-left: 0; }
.two-col td:last-child { padding-right: 0; }
table.details {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
table.details td {
border: 1px solid #e5e7eb;
padding: 1.1mm 1.8mm;
vertical-align: top;
}
table.details td.label {
width: 40%;
font-weight: bold;
color: #374151;
background: #f9fafb;
}
table.details td.value {
width: 60%;
color: #111827;
font-weight: bold;
}
.sign {
width: 100%;
border-collapse: collapse;
margin-top: 2mm;
}
.sign td {
width: 50%;
border: 1px solid #d1d5db;
padding: 1.8mm 2.2mm;
vertical-align: bottom;
}
.sig-label {
font-size: 8pt;
font-weight: bold;
color: #374151;
margin-bottom: 3.5mm;
}
.sig-line {
border-bottom: 1px solid #111827;
height: 4mm;
}
.payments {
margin-top: 2mm;
border-top: 1px solid #d1d5db;
padding-top: 1.8mm;
}
.payments-title {
font-size: 9pt;
font-weight: bold;
margin: 0 0 1.2mm 0;
color: #111827;
}
.payments p { margin: 0 0 1.2mm 0; }
</style>
<div class="watermark">DRAFT</div>
<div class="page">
<div class="top">
<div class="logo">
{{ pdf_asset("Fen Edge Logo") }}
</div>
<div class="head">
<p class="title">Membership Renewal 2026-2027</p>
<p class="subtitle">Fen Edge Community Association</p>
<div class="fee-banner">Group Membership Fee: GBP15</div>
<p class="note">Please amend any details that have changed before returning this form.</p>
</div>
</div>
<div class="pane">
<div class="pane-title">Please Amend: Account And Public Contact</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Account</td><td class="value">{{ account_name }}</td></tr>
<tr><td class="label">Type</td><td class="value">{{ account_type }}</td></tr>
<tr><td class="label">Sector</td><td class="value">{{ account_sector }}</td></tr>
<tr><td class="label">Public Location</td><td class="value">{{ public_location }}</td></tr>
<tr><td class="label">Members</td><td class="value">{{ account_no_of_members }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Public Contact</td><td class="value">{{ public_contact_name }}</td></tr>
<tr><td class="label">Public Email</td><td class="value">{{ public_contact_public_email }}</td></tr>
<tr><td class="label">Public Phone</td><td class="value">{{ public_contact_public_phone }}</td></tr>
<tr><td class="label">Facebook</td><td class="value">{{ facebook }}</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Primary Contact (Contact 1)</div>
<div class="pane-body">
<table class="details">
<tr><td class="label">Name</td><td class="value">{{ contact_1_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_1_position }}</td></tr>
<tr><td class="label">Address</td><td class="value">{{ contact_1_address_1 }} {{ contact_1_address_2 }}, {{ contact_1_town }}, {{ contact_1_county }}, {{ contact_1_postcode }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_1_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_1_phone }} {{ contact_1_mobile }}</td></tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Secondary Contacts</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Contact 2 Name</td><td class="value">{{ contact_2_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_2_position }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_2_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_2_phone }} {{ contact_2_mobile }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Fen Contact Name</td><td class="value">{{ fen_contact_name }}</td></tr>
<tr><td class="label">Fen Contact Email</td><td class="value">{{ fen_contact_email }}</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<table class="sign">
<tr>
<td>
<div class="sig-label">Signed</div>
<div class="sig-line"></div>
</td>
<td>
<div class="sig-label">Date</div>
<div class="sig-line"></div>
</td>
</tr>
</table>
<div class="payments">
<p class="payments-title">Payment Options</p>
<p><strong>1. Cheque:</strong> payable to <strong>Fen Edge Community Association</strong>, sent with this form to <strong>FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA</strong>.</p>
<p><strong>2. BACS:</strong> <strong>Sort Code 20-17-22</strong>, <strong>Account No. 40349178</strong>; then scan and email the form to <strong>info@fenedge.co.uk</strong>.</p>
</div>
</div>

288
formats/renewals_v3.html Normal file
View File

@ -0,0 +1,288 @@
<!-- Template Version: 3 -->
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 10mm; }
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 9pt;
line-height: 1.24;
margin: 0;
padding: 0;
}
.page { width: 100%; position: relative; z-index: 1; }
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 72pt;
color: rgba(220, 38, 38, 0.12);
font-weight: bold;
letter-spacing: 7px;
z-index: 0;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.header {
margin-bottom: 5mm;
min-height: 28mm;
}
.logo-right {
float: right;
width: 42mm;
margin-left: 4mm;
text-align: right;
}
.header-text {
overflow: hidden;
}
.title {
font-size: 14.5pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 1mm 0;
}
.subtitle {
margin: 0;
font-size: 9.2pt;
color: #4b5563;
}
.rule {
margin: 2.6mm 0 2.2mm 0;
border: 0;
border-top: 2px solid #d97706;
}
.fee-banner {
margin: 0 0 2.2mm 0;
padding: 1.8mm 2.5mm;
background: #fff7ed;
border-left: 4px solid #d97706;
font-size: 10pt;
font-weight: bold;
color: #9a3412;
}
.note {
margin: 0 0 1.2mm 0;
font-style: italic;
color: #374151;
}
.note-line {
margin: 0 0 3.6mm 0;
color: #111827;
font-weight: bold;
}
.pane {
border: 1px solid #d1d5db;
margin: 0 0 3.2mm 0;
}
.pane-title {
background: #f3f4f6;
border-bottom: 1px solid #d1d5db;
padding: 1.4mm 2.2mm;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.4px;
font-size: 8pt;
color: #374151;
}
.pane-body { padding: 2mm 2.2mm; }
.two-col {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.two-col td {
width: 50%;
vertical-align: top;
padding: 0 1.4mm;
}
.two-col td:first-child { padding-left: 0; }
.two-col td:last-child { padding-right: 0; }
table.details {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
table.details td {
border: 1px solid #e5e7eb;
padding: 1.4mm 2mm;
vertical-align: top;
}
table.details td.label {
width: 40%;
font-weight: bold;
color: #374151;
background: #f9fafb;
}
table.details td.value {
width: 60%;
color: #111827;
font-weight: bold;
}
.sign {
width: 100%;
border-collapse: collapse;
margin-top: 3.2mm;
}
.sign td {
width: 50%;
border: 1px solid #d1d5db;
padding: 2.2mm 2.5mm;
vertical-align: bottom;
}
.sig-label {
font-size: 8.1pt;
font-weight: bold;
color: #374151;
margin-bottom: 4.6mm;
}
.sig-line {
border-bottom: 1px solid #111827;
height: 4mm;
}
.payments {
margin-top: 3.2mm;
border-top: 1px solid #d1d5db;
padding-top: 2.6mm;
}
.payments-title {
font-size: 9.2pt;
font-weight: bold;
margin: 0 0 1.8mm 0;
color: #111827;
}
.payments p { margin: 0 0 1.8mm 0; }
.clear { clear: both; }
</style>
<div class="watermark">DRAFT</div>
<div class="page">
<div class="header">
<div class="logo-right">{{ pdf_asset("Fen Edge Logo") }}</div>
<div class="header-text">
<p class="title">Membership Renewal 2026-2027</p>
<p class="subtitle">Fen Edge Community Association</p>
<hr class="rule">
<div class="fee-banner">Group Membership Fee: GBP15</div>
<p class="note">Please amend any details that have changed before returning this form.</p>
<p class="note-line">Please amend:</p>
</div>
<div class="clear"></div>
</div>
<div class="pane">
<div class="pane-title">Account And Public Contact</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Account</td><td class="value">{{ account_name }}</td></tr>
<tr><td class="label">Type</td><td class="value">{{ account_type }}</td></tr>
<tr><td class="label">Sector</td><td class="value">{{ account_sector }}</td></tr>
<tr><td class="label">Public Location</td><td class="value">{{ public_location }}</td></tr>
<tr><td class="label">Members</td><td class="value">{{ account_no_of_members }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Public Contact</td><td class="value">{{ public_contact_name }}</td></tr>
<tr><td class="label">Public Email</td><td class="value">{{ public_contact_public_email }}</td></tr>
<tr><td class="label">Public Phone</td><td class="value">{{ public_contact_public_phone }}</td></tr>
<tr><td class="label">Facebook</td><td class="value">tbd</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Primary Contact (Contact 1)</div>
<div class="pane-body">
<table class="details">
<tr><td class="label">Name</td><td class="value">{{ contact_1_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_1_position }}</td></tr>
<tr><td class="label">Address</td><td class="value">{{ contact_1_address_1 }} {{ contact_1_address_2 }}, {{ contact_1_town }}, {{ contact_1_county }}, {{ contact_1_postcode }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_1_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_1_phone }} {{ contact_1_mobile }}</td></tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Secondary Contacts</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Contact 2 Name</td><td class="value">{{ contact_2_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_2_position }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_2_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_2_phone }} {{ contact_2_mobile }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Fen Contact Name</td><td class="value">{{ fen_contact_name }}</td></tr>
<tr><td class="label">Fen Contact Email</td><td class="value">{{ fen_contact_email }}</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<table class="sign">
<tr>
<td>
<div class="sig-label">Signed</div>
<div class="sig-line"></div>
</td>
<td>
<div class="sig-label">Date</div>
<div class="sig-line"></div>
</td>
</tr>
</table>
<div class="payments">
<p class="payments-title">Payment Options</p>
<p><strong>1. Cheque:</strong> payable to <strong>Fen Edge Community Association</strong>, sent with this form to <strong>FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA</strong>.</p>
<p><strong>2. BACS:</strong> <strong>Sort Code 20-17-22</strong>, <strong>Account No. 40349178</strong>; then scan and email the form to <strong>info@fenedge.co.uk</strong>.</p>
</div>
</div>

299
formats/renewals_v4.html Normal file
View File

@ -0,0 +1,299 @@
<!-- Template Version: 4 -->
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 10mm; }
body {
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
font-size: 9pt;
line-height: 1.25;
margin: 0;
padding: 0;
}
.page { width: 100%; position: relative; z-index: 1; }
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 72pt;
color: rgba(220, 38, 38, 0.12);
font-weight: bold;
letter-spacing: 7px;
z-index: 0;
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.header {
margin-bottom: 5.5mm;
min-height: 29mm;
}
.logo-right {
float: right;
width: 42mm;
margin-left: 4mm;
text-align: right;
}
.header-text { overflow: hidden; }
.title {
font-size: 14.5pt;
font-weight: bold;
color: #0f172a;
margin: 0 0 1mm 0;
}
.subtitle {
margin: 0;
font-size: 9.2pt;
color: #4b5563;
}
.rule {
margin: 2.8mm 0 2.4mm 0;
border: 0;
border-top: 2px solid #d97706;
}
.fee-banner {
margin: 0 0 2.4mm 0;
padding: 1.9mm 2.5mm;
background: #fff7ed;
border-left: 4px solid #d97706;
font-size: 10pt;
font-weight: bold;
color: #9a3412;
}
.note {
margin: 0 0 1.3mm 0;
font-style: italic;
color: #374151;
}
.note-line {
margin: 0 0 3.8mm 0;
color: #111827;
font-weight: bold;
}
.pane {
border: 1px solid #d1d5db;
margin: 0 0 3.6mm 0;
}
.pane-title {
background: #f3f4f6;
border-bottom: 1px solid #d1d5db;
padding: 1.5mm 2.2mm;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.4px;
font-size: 8pt;
color: #374151;
}
.pane-body { padding: 2.1mm 2.2mm; }
.two-col {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.two-col td {
width: 50%;
vertical-align: top;
padding: 0 1.5mm;
}
.two-col td:first-child { padding-left: 0; }
.two-col td:last-child { padding-right: 0; }
table.details {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
table.details td {
border: 1px solid #e5e7eb;
padding: 1.5mm 2mm;
vertical-align: top;
}
table.details td.label {
width: 40%;
font-weight: bold;
color: #374151;
background: #f9fafb;
}
table.details td.value {
width: 60%;
color: #111827;
font-weight: bold;
}
.instruction-box {
margin: 0 0 3.6mm 0;
border: 1px solid #d1d5db;
background: #f9fafb;
padding: 2mm 2.4mm;
color: #374151;
}
.sign {
width: 100%;
border-collapse: collapse;
margin-top: 3.2mm;
}
.sign td {
width: 50%;
border: 1px solid #d1d5db;
padding: 2.4mm 2.6mm;
vertical-align: bottom;
}
.sig-label {
font-size: 8.1pt;
font-weight: bold;
color: #374151;
margin-bottom: 4.8mm;
}
.sig-line {
border-bottom: 1px solid #111827;
height: 4.2mm;
}
.payments {
margin-top: 3.8mm;
border-top: 1px solid #d1d5db;
padding-top: 2.8mm;
}
.payments-title {
font-size: 9.2pt;
font-weight: bold;
margin: 0 0 2mm 0;
color: #111827;
}
.payments p { margin: 0 0 1.9mm 0; }
.clear { clear: both; }
</style>
<div class="watermark">DRAFT</div>
<div class="page">
<div class="header">
<div class="logo-right">{{ pdf_asset("Fen Edge Logo") }}</div>
<div class="header-text">
<p class="title">Membership Renewal 2026-2027</p>
<p class="subtitle">Fen Edge Community Association</p>
<hr class="rule">
<div class="fee-banner">Group Membership Fee: GBP15</div>
<p class="note">Please amend any details that have changed before returning this form.</p>
<p class="note-line">Please amend:</p>
</div>
<div class="clear"></div>
</div>
<div class="pane">
<div class="pane-title">Account And Public Contact</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Account</td><td class="value">{{ account_name }}</td></tr>
<tr><td class="label">Type</td><td class="value">{{ account_type }}</td></tr>
<tr><td class="label">Sector</td><td class="value">{{ account_sector }}</td></tr>
<tr><td class="label">Public Location</td><td class="value">{{ public_location }}</td></tr>
<tr><td class="label">Members</td><td class="value">{{ account_no_of_members }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Public Contact</td><td class="value">{{ public_contact_name }}</td></tr>
<tr><td class="label">Public Email</td><td class="value">{{ public_contact_public_email }}</td></tr>
<tr><td class="label">Public Phone</td><td class="value">{{ public_contact_public_phone }}</td></tr>
<tr><td class="label">Facebook</td><td class="value">tbd</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Primary Contact (Contact 1)</div>
<div class="pane-body">
<table class="details">
<tr><td class="label">Name</td><td class="value">{{ contact_1_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_1_position }}</td></tr>
<tr><td class="label">Address</td><td class="value">{{ contact_1_address_1 }} {{ contact_1_address_2 }}, {{ contact_1_town }}, {{ contact_1_county }}, {{ contact_1_postcode }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_1_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_1_phone }} {{ contact_1_mobile }}</td></tr>
</table>
</div>
</div>
<div class="pane">
<div class="pane-title">Secondary Contacts</div>
<div class="pane-body">
<table class="two-col">
<tr>
<td>
<table class="details">
<tr><td class="label">Contact 2 Name</td><td class="value">{{ contact_2_name }}</td></tr>
<tr><td class="label">Position</td><td class="value">{{ contact_2_position }}</td></tr>
<tr><td class="label">Email</td><td class="value">{{ contact_2_email }}</td></tr>
<tr><td class="label">Phone / Mobile</td><td class="value">{{ contact_2_phone }} {{ contact_2_mobile }}</td></tr>
</table>
</td>
<td>
<table class="details">
<tr><td class="label">Fen Contact Name</td><td class="value">{{ fen_contact_name }}</td></tr>
<tr><td class="label">Fen Contact Email</td><td class="value">{{ fen_contact_email }}</td></tr>
</table>
</td>
</tr>
</table>
</div>
</div>
<div class="instruction-box">
Please check your groups public contact details on the FECA website:
<strong>www.fenedge.co.uk/full-members-list</strong> and advise any changes.
</div>
<table class="sign">
<tr>
<td>
<div class="sig-label">Signed</div>
<div class="sig-line"></div>
</td>
<td>
<div class="sig-label">Date</div>
<div class="sig-line"></div>
</td>
</tr>
</table>
<div class="payments">
<p class="payments-title">Payment Options</p>
<p><strong>1. Cheque:</strong> payable to <strong>Fen Edge Community Association</strong>, sent with this form to <strong>FECA Community Office, Cottenham Village College, Cottenham, CB24 8UA</strong>.</p>
<p><strong>2. BACS:</strong> <strong>Sort Code 20-17-22</strong>, <strong>Account No. 40349178</strong>; then scan and email the form to <strong>info@fenedge.co.uk</strong>.</p>
</div>
</div>

View File

@ -1,14 +1,18 @@
import { defineConfig } from '@playwright/test';
const isRemoteAdmin = String(process.env.E2E_REMOTE_ADMIN || '') === '1';
export default defineConfig({
testDir: './tests/e2e/specs',
globalSetup: './tests/e2e/global-setup.mjs',
workers: 1,
timeout: 180000,
expect: { timeout: 15000 },
retries: 0,
reporter: [['list']],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8080',
trace: 'on-first-retry'
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8092',
trace: 'on-first-retry',
storageState: isRemoteAdmin ? './working/e2e-remote-auth.json' : undefined
}
});

View File

@ -373,7 +373,7 @@ Provide CRUD storage for PDF assets with at least:
* Asset binaries are stored in DB table (`LONGBLOB`) for portability and backup simplicity.
* Allowed upload formats: `png`, `jpg/jpeg`, `svg`.
* Scope/ownership: global asset library, all authenticated users may CRUD.
* Scope/ownership: global asset library, editor-capable users (WordPress `edit_pages`) may CRUD.
* Asset naming: unique case-insensitive `name`; renaming does not auto-migrate existing asset insertions in templates and should warn user.
* Marker syntax is removed. PDF asset insertion uses Twig syntax.
* Helper name is `pdf_asset(name)` as a global Twig helper, not a filter.

View File

@ -100,6 +100,10 @@ digit = "0"…"9" ;
## Semantics (v1.5)
* `built_in_source` maps to a predefined table alias.
* For `accounts`, the compiler must implicitly left-join account picklists so additional readable virtual fields are available:
* `accounts.type` from `picklist_account_type.value`
* `accounts.public_location` from `picklist_public_location.value`
* `accounts.account_sector` from `picklist_sector.value`
* `table_ref` maps directly to a table in the remote members database (`schema.table`).
* For `table_ref`, validation must confirm the table exists.
* Query result shape must be source-faithful and deterministic per DSL:
@ -420,8 +424,9 @@ WordPress REST route contract:
Auth/capability requirements:
* all routes require authenticated WordPress users;
* create/update/delete routes require a mailshot management capability (for example `manage_mailshots`);
* read/preview/validate routes require a mailshot read capability (for example `read_mailshots`).
* in this plugin, editor-capable operational access is the baseline:
* create/update/delete/read/preview/validate all require WordPress `edit_pages` (editor or administrator);
* plugin Setup/settings remains administrator-only under WordPress `manage_options` (defined in top-level policy).
All endpoints must enforce server-side validation even if client validates first.

View File

@ -0,0 +1,238 @@
# Renewal Views
## Scope
Define SQL views in `fenedgec_members` to support renewal-letter generation.
These views must read only from the existing local tables:
* `fenedgec_members.accounts`
* `fenedgec_members.contacts`
* `fenedgec_members.renewals`
* `fenedgec_members.picklist_account_type`
* `fenedgec_members.picklist_sector`
* `fenedgec_members.picklist_public_location`
Per `requirements/environment.md`:
* Recipient/source reads are in `MEMBERS_REMOTE_MYSQL_DB` (here: `fenedgec_members`).
* No fallback behavior is defined in this requirement.
## Source Filters (Current Schema)
Use only active rows:
* Accounts: `a.is_deleted = 0`
* Contacts: `c.is_deleted = 0`
* Renewals: no delete flag exists in `renewals`; include all renewal rows unless other filter criteria apply.
## Required Output Data
Account details:
* `accounts.id`
* `accounts.name`
* `accounts.account_audience`
* `accounts.account_no_of_members`
* `accounts.account_type_id` plus joined `picklist_account_type.value` exposed as `accounts.type`
* `accounts.public_location_id` plus joined `picklist_public_location.value` exposed as `accounts.public_location`
* `accounts.sector_id` plus joined `picklist_sector.value` exposed as `accounts.account_sector`
Renewal details:
* `renewals.id`
* `renewals.account_id`
* `renewals.renewal_year`
* `renewals.status`
* `renewals.payment_method`
* `renewals.payment_amount`
* `renewals.payment_date`
* `renewals.selected`
* `renewals.created_at`
* `renewals.updated_at`
Contact 1 details (`contacts.is_contact_1 = 1`):
* Name
* Position/title
* Address
* Email
* Phone
* Mobile
Contact 2 details (`contacts.is_contact_2 = 1`):
* Name
* Position/title
* Email
* Phone
* Mobile
Fen Edge News contact details (`contacts.is_fen_1 = 1`):
* Name
* Email
Public contact details (contacts.is_public_contact = 1):
* Public Phone
* Public Email
## View Hierarchy
Create the following views:
* `renewal_members` (accounts whose account type picklist slug is `member`)
* `renewal_pending` (renewals with `status = 'pending'`)
* `renewal_contact_1` (contacts where `is_contact_1 = 1`)
* `renewal_contact_2` (contacts where `is_contact_2 = 1`)
* `renewal_fen_contact` (contacts where `is_fen_1 = 1`)
* `renewal_public_contact` (contacts where `is_public_contact = 1`)
* `renewal_accounts_with_contacts` (final denormalized export view joining the above plus `renewal_pending`)
## Column Rules
### `renewal_members`
Required columns:
* `account_id` -> `accounts.id`
* `account_name` -> `accounts.name`
* `account_audience` -> `accounts.account_audience`
* `account_no_of_members` -> `accounts.account_no_of_members`
* `account_type_id` -> `accounts.account_type_id`
* `account_type` -> `picklist_account_type.value` (equivalent to `accounts.type`)
* `sector_id` -> `accounts.sector_id`
* `account_sector` -> `picklist_sector.value` (equivalent to `accounts.account_sector`)
* `public_location_id` -> `accounts.public_location_id`
* `public_location` -> `picklist_public_location.value` (equivalent to `accounts.public_location`)
Filter:
* Member-status check must use account type picklist `slug`.
* `LOWER(TRIM(COALESCE(picklist_account_type.slug, ''))) = 'member'`
### Contact views
Each contact view must expose all contact fields needed for renewals, prefixed by view role:
* `renewal_contact_1`: prefix `contact_1_`
* `renewal_contact_2`: prefix `contact_2_`
* `renewal_fen_contact`: prefix `fen_contact_`
* `renewal_public_contact`: prefix `public_contact_`
Minimum required prefixed columns:
* `..._contact_id` -> `contacts.id`
* `..._account_id` -> `contacts.account_id`
* `..._name` -> `TRIM(CONCAT_WS(' ', contacts.first_name, contacts.middle_name, contacts.last_name))`
* `..._position` -> `contacts.title`
* `..._address_1` -> `contacts.contact_address_1`
* `..._address_2` -> `contacts.contact_address_2`
* `..._town` -> `contacts.contact_town`
* `..._county` -> `contacts.contact_county`
* `..._postcode` -> `contacts.contact_postcode`
* `..._email` -> `contacts.contact_email_1`
* `..._phone` -> `contacts.home_phone`
* `..._mobile` -> `contacts.mobile`
### `renewal_public_contact`
Required columns:
* `public_contact_contact_id` -> `contacts.id`
* `public_contact_account_id` -> `contacts.account_id`
* `public_contact_public_phone` -> `contacts.public_phone`
* `public_contact_public_email` -> `contacts.public_email`
Filter:
* `contacts.is_public_contact = 1`
### `renewal_pending`
Required columns:
* `renewal_id` -> `renewals.id`
* `renewal_account_id` -> `renewals.account_id`
* `renewal_year` -> `renewals.renewal_year`
* `renewal_status` -> `renewals.status`
* `renewal_selected` -> `renewals.selected`
* `renewal_payment_method` -> `renewals.payment_method`
* `renewal_payment_amount` -> `renewals.payment_amount`
* `renewal_payment_date` -> `renewals.payment_date`
* `renewal_created_at` -> `renewals.created_at`
* `renewal_updated_at` -> `renewals.updated_at`
Filter:
* `LOWER(TRIM(COALESCE(renewals.status, ''))) = 'pending'`
### `renewal_accounts_with_contacts`
Join key:
* `renewal_members.account_id = renewal_pending.renewal_account_id`
* `renewal_members.account_id = renewal_contact_X.contact_X_account_id`
* `renewal_members.account_id = renewal_public_contact.public_contact_account_id`
Join type:
* Left join all contact views so an account still appears when some contact roles are missing.
Output:
* All `renewal_members` columns
* All `renewal_pending` columns
* Selected prefixed columns from each contact view
## Multiple-Match Tie-Break Rules
If multiple contacts match the same role for one account:
* Pick the lowest `contacts.id`.
* This rule must be deterministic and documented in view SQL (for example via grouped subquery/min id).
## Null and Empty Handling
* Preserve NULL values from source fields.
* Computed `..._name` may be empty string when all name parts are blank.
* Do not convert NULL to placeholder text such as `N/A`.
## Legacy Name Review (Flagged)
The following names in prior drafts do not match current schema and are explicitly flagged:
* `members.Account` -> `NON-EXISTENT` (use `fenedgec_members.accounts`)
* `members.Contact` -> `NON-EXISTENT` (use `fenedgec_members.contacts`)
* `members.renewals` -> `NON-EXISTENT` in that schema name (use `fenedgec_members.renewals`)
* `Account.Name` -> `NON-EXISTENT` (use `accounts.name`)
* `Account.Type` -> `NON-EXISTENT` (use `accounts.account_type_id` + `picklist_account_type.slug` for member-status checks, and `picklist_account_type.value` for display)
* `Account.AccountAudience` -> `NON-EXISTENT` (use `accounts.account_audience`)
* `Account.AccountNoOfMembers` -> `NON-EXISTENT` (use `accounts.account_no_of_members`)
* `Contact.Accountid` -> `NON-EXISTENT` (use `contacts.account_id`)
* `First`/`Middle`/`Last` -> `NON-EXISTENT` (use `first_name`/`middle_name`/`last_name`)
* `JobTitle` -> `NON-EXISTENT`; mapped to `contacts.title` (`AMBIGUOUS: verify business meaning`)
* `Address1`/`Address2`/`Town`/`County`/`Postcode` -> `NON-EXISTENT` (use `contact_address_1`/`contact_address_2`/`contact_town`/`contact_county`/`contact_postcode`)
* `Email` -> `NON-EXISTENT`; mapped to `contacts.contact_email_1` (`AMBIGUOUS: confirm whether `contact_email_2 ` should also be included`)
* `Home` -> `NON-EXISTENT` (use `contacts.home_phone`)
* `Mobile` -> `NON-EXISTENT` (use `contacts.mobile`)
* `Contact1` -> `NON-EXISTENT` (use `contacts.is_contact_1`)
* `Contact2` -> `NON-EXISTENT` (use `contacts.is_contact_2`)
* `FENContact1` -> `NON-EXISTENT` (use `contacts.is_fen_1`)
* `deleted` -> `NON-EXISTENT` on accounts/contacts (use `is_deleted`)
## Acceptance Criteria
* All views compile in `fenedgec_members`.
* `renewal_members` contains only active member accounts.
* `renewal_pending` contains only rows where status is `pending`.
* `renewal_contact_1` only includes contacts flagged `is_contact_1 = 1`.
* `renewal_contact_2` only includes contacts flagged `is_contact_2 = 1`.
* `renewal_fen_contact` only includes contacts flagged `is_fen_1 = 1`.
* `renewal_public_contact` only includes contacts flagged `is_public_contact = 1`.
* `renewal_accounts_with_contacts` returns one row per pending renewal row (no duplicated `renewal_id`).
* `account_audience` and `account_no_of_members` are present in final output.
* Renewal fields listed above are present in final output.
* Account picklist-backed values are present in final output as `account_type`/`account_sector`/`public_location` (or equivalent `accounts.type`/`accounts.account_sector`/`accounts.public_location` aliases).
* Member-status check is based on `picklist_account_type.slug`, not the label text.
* No remote cross-system query is used by these views.

View File

@ -0,0 +1,25 @@
# Third-Party Software Requirements
## 1. Scope
This document defines third-party runtime dependencies used by the Mailshots admin UI.
## 2. Message Editor Dependency
- Component: Jodit Editor (open source)
- Purpose: Message template editing with robust Visual/Code workflow.
- Required version: `4.7.9` (pinned)
- Runtime assets:
- `https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.css`
- `https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.js`
## 3. Hard Requirement Policy
- Message editor uses Jodit with no fallback editor.
- If Jodit is unavailable (load failure, blocked CDN, missing global), opening **Edit Message** must fail hard.
- Failure condition is intentional to prevent silent degradation into a less reliable editing path.
## 4. Operational Notes
- Environments must allow retrieval of the pinned Jodit assets.
- Any version change must be reviewed and explicitly updated in code and in this file.

View File

@ -54,6 +54,21 @@ Local governing baseline:
- Validate read/write access for mailshot tables/assets in the new remote mailshot database.
- Validate that removed local-domain sources (issue/page/article/advertiser/ads) are not required by the migrated flow.
## Access Control Policy
1. Plugin settings access
- The plugin Setup/settings page is administrator-only.
- WordPress capability required: `manage_options`.
2. Operational feature access
- WordPress editors (and administrators) must be able to use all non-setup mailshot features.
- This includes full CRUD where available for:
- data sources
- mailshots
- attachments
- PDF assets
- Baseline WordPress capability for operational pages/endpoints: `edit_pages`.
## Research Outcomes (2026-04-20)
1. PDF engine suitability (`dompdf`)

View File

@ -47,6 +47,8 @@ For pages using info/statistics/actions/data regions:
* Persist user-adjusted split ratios per page.
* On narrow viewports, collapse split layouts to a vertical stack and disable drag interaction.
* Keep each pane independently scrollable when content exceeds pane bounds.
*
*
## Common UX Rules
@ -54,6 +56,9 @@ For pages using info/statistics/actions/data regions:
* Hide CRUD forms when not in use.
* Use red visual treatment for destructive actions.
* Provide spacing between action buttons and panel edges.
* Use "Save" / "Quit" to save (and leave) a modal or "Quit" to leave it.
* If "Save" fails - display a message and do not leave the modal.
* Under no conditions allow a user-entry error to lose user entered data - always allow them a way to recover without reentering work.
## Default Table Behavior

View File

@ -0,0 +1,85 @@
# Fallback Audit - 2026-04-22
Policy source: `requirements/environment.md`
Hard constraints include:
1) No fallbacks unless explicitly listed
2) Fail fast on missing/invalid data paths
3) Show explicit errors at point of detection
## Confirmed fallback behavior (policy violations)
1. `feca_mailshots_plugin/src/Application/TemplateRenderer.php:26`
- `strict_variables` is `false`, so missing Twig variables silently render as empty values.
- This is a runtime data fallback.
2. `feca_mailshots_plugin/src/Application/DataSourceService.php:175-187`
- `listSchemas()` catches `Throwable` and silently continues with only configured members schema.
- Explicitly documented as fallback in comment.
3. `feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php:314-370`
- `getTemplateHtmlValue()` has multiple layered value fallbacks and swallow-catch blocks.
- This is intentional resilience, but still fallback behavior.
4. `feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php:659-663`
- Save path catches read failure and falls back to `templateJodit.value`.
5. `feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php:555-559`
- Token load failure clears token list and continues UI instead of hard erroring.
6. `feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php:595-597`
- PDF asset list failure is replaced with an inline placeholder option and flow continues.
7. `feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php:48-66`
- `uploadedFileToBase64(..., $fallback)` keeps prior base64 value when upload absent/invalid.
8. `feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:182-186`
- DSL builder auto-selects `fenedgec_members` when present.
- This is auto-default selection fallback.
9. `feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php:54-56`
- Auto-selects first mailshot if request has no `mailshot_id`.
10. `feca_mailshots_plugin/src/Infrastructure/PhpImapAppender.php:21,24,25`
- Defaults IMAP port/folder/flags to `993` / `Sent` / `/imap/ssl` if missing.
11. `feca_mailshots_plugin/src/Repository/MailCredentialRepository.php:63-68`
- Empty submitted passwords are replaced with existing stored passwords.
12. `feca_mailshots_plugin/src/bootstrap.php:11-17`
- Missing/invalid setup option values become empty strings and only fail later when DB router validates config.
- Not silent success, but still fallback acquisition behavior.
## Defaulting that should be reviewed for strict mode
1. `feca_mailshots_plugin/src/Application/AttachmentService.php:29`
- MIME defaults to `application/octet-stream`.
2. `feca_mailshots_plugin/src/Application/PdfAssetService.php:29-31`
- MIME defaults to `application/octet-stream`; justification defaults to `in-place`; width/height default to zero.
3. `feca_mailshots_plugin/src/Admin/AttachmentsAdminPage.php:65`
- Edit form defaults MIME to `application/octet-stream`.
4. `feca_mailshots_plugin/src/Admin/PdfAssetsAdminPage.php:65-68`
- Edit form defaults MIME/width/height/justification values.
5. `feca_mailshots_plugin/src/Admin/ProfileAdminPage.php:88,99,102,103,129,136,139,140`
- SMTP/IMAP ports and IMAP folder/flags are defaulted in UI and request extraction.
6. `feca_mailshots_plugin/src/Admin/SetupAdminPage.php:70,93,120`
- DB port defaults to `3306` in UI and request extraction.
## Notes
- Missing Twig dependency is currently a hard error (`TemplateRenderer.php:15-17`) and is compliant with the standing instruction.
- This audit is code-focused and excludes test-only scaffolding.
## Suggested strict/no-fallback remediation order
1. Enforce Twig strict variable failures (`strict_variables => true`) and surface template variable errors in UI.
2. Remove silent catches in `MailshotsAdminPage` template/token/asset loaders; show local hard error and block save.
3. Remove `DataSourceService::listSchemas()` silent fallback catch; return explicit error path.
4. Remove auto-selections (first mailshot / default schema); require explicit user choice.
5. Remove implicit IMAP/SMTP/asset defaults; require explicit configured values.
6. Replace password-preserve-on-empty with explicit "unchanged" intent flag instead of implicit fallback.

Binary file not shown.

BIN
samples/test attachment.odt Normal file

Binary file not shown.

BIN
samples/test attachment.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,250 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolve_repo_root() {
local candidate
for candidate in "${SCRIPT_DIR}/.." "${SCRIPT_DIR}/../.." "${PWD}"; do
if [[ -f "${candidate}/credentials/.env" ]]; then
(cd "${candidate}" && pwd)
return 0
fi
done
return 1
}
if ! REPO_ROOT="$(resolve_repo_root)"; then
echo "Error: could not locate repository root containing credentials/.env" >&2
exit 1
fi
ENV_FILE="${REPO_ROOT}/credentials/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "Error: credentials file not found: ${ENV_FILE}" >&2
exit 1
fi
load_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
line="${line#*=}"
line="${line%\"}"
line="${line#\"}"
line="${line%\'}"
line="${line#\'}"
printf '%s' "${line}"
}
DB_NAME="$(load_env_value MAILSHOTS_REMOTE_MYSQL_DB)"
MYSQL_USER="$(load_env_value REMOTE_MYSQL_USER)"
MYSQL_PASSWORD="$(load_env_value REMOTE_MYSQL_PASSWORD)"
REMOTE_MYSQL_HOST="$(load_env_value REMOTE_MYSQL_HOST)"
REMOTE_MYSQL_PORT="$(load_env_value REMOTE_MYSQL_PORT)"
LOCAL_TUNNEL_PORT="${LOCAL_PORT:-13306}"
# Match scripts/mysql_tunnel.sh defaults unless explicitly overridden.
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT="${MYSQL_PORT:-${LOCAL_TUNNEL_PORT}}"
if [[ -z "${DB_NAME}" ]]; then
echo "Error: MAILSHOTS_REMOTE_MYSQL_DB is missing in ${ENV_FILE}" >&2
exit 1
fi
if [[ -z "${MYSQL_USER}" || -z "${MYSQL_PASSWORD}" ]]; then
echo "Error: REMOTE_MYSQL_USER/REMOTE_MYSQL_PASSWORD missing in ${ENV_FILE}" >&2
exit 1
fi
if [[ -z "${MYSQL_HOST}" || -z "${MYSQL_PORT}" ]]; then
echo "Error: MYSQL host/port is empty. Set REMOTE_MYSQL_HOST/REMOTE_MYSQL_PORT or MYSQL_HOST/MYSQL_PORT." >&2
exit 1
fi
DRY_RUN=0
AUTO_YES=0
INCLUDE_CREDENTIALS=0
USE_REMOTE=0
INCLUDE_MAILSHOT_TEST=0
# Strict, test-suite-specific markers only.
ARTEFACT_REGEX='^(e2e_|phase2_db_access_|phase3_|phase4_)'
MAILSHOT_PURPOSE_REGEX='^(e2e_|Phase3 Mailshot |Phase4 )'
usage() {
cat <<'USAGE'
Usage: cleanup_testing_artefacts.sh [options]
Options:
--dry-run Show row counts only; make no changes.
--yes Skip confirmation prompt.
--include-credentials Also delete test-marked rows in mailshot_credentials.
--include-mailshot-test Also delete test-marked rows in mailshot_test.
--remote Connect directly to REMOTE_MYSQL_HOST:REMOTE_MYSQL_PORT.
-h, --help Show this help text.
Environment overrides:
LOCAL_PORT, MYSQL_HOST, MYSQL_PORT
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--yes) AUTO_YES=1 ;;
--include-credentials) INCLUDE_CREDENTIALS=1 ;;
--include-mailshot-test) INCLUDE_MAILSHOT_TEST=1 ;;
--remote) USE_REMOTE=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage
exit 1
;;
esac
shift
done
if [[ "${USE_REMOTE}" -eq 1 ]]; then
MYSQL_HOST="${REMOTE_MYSQL_HOST}"
MYSQL_PORT="${REMOTE_MYSQL_PORT}"
fi
MYSQL_CMD=(mysql --protocol=TCP "-h${MYSQL_HOST}" "-P${MYSQL_PORT}" "-u${MYSQL_USER}" "-p${MYSQL_PASSWORD}" -N -B)
echo "Target database: ${DB_NAME}"
echo "MySQL endpoint: ${MYSQL_HOST}:${MYSQL_PORT}"
echo "Cleanup mode: selective delete only (no TRUNCATE)."
echo "Artefact regex: ${ARTEFACT_REGEX}"
echo "Mailshot purpose regex: ${MAILSHOT_PURPOSE_REGEX}"
for t in mailshot_last_run mailshot_queries mailshot_attachments mailshot_pdf_assets mailshots mailshot_credentials mailshot_test; do
exists="$("${MYSQL_CMD[@]}" -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name='${t}';")"
if [[ "${exists}" != "1" ]]; then
echo "Error: required table ${DB_NAME}.${t} does not exist." >&2
exit 1
fi
done
candidate_counts_sql="
SELECT 'mailshot_queries' AS t, COUNT(*) AS c
FROM \`${DB_NAME}\`.\`mailshot_queries\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}'
UNION ALL
SELECT 'mailshot_attachments' AS t, COUNT(*) AS c
FROM \`${DB_NAME}\`.\`mailshot_attachments\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(\`file_name\`, '') REGEXP '${ARTEFACT_REGEX}'
UNION ALL
SELECT 'mailshot_pdf_assets' AS t, COUNT(*) AS c
FROM \`${DB_NAME}\`.\`mailshot_pdf_assets\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(\`file_name\`, '') REGEXP '${ARTEFACT_REGEX}'
UNION ALL
SELECT 'mailshots' AS t, COUNT(*) AS c
FROM \`${DB_NAME}\`.\`mailshots\`
WHERE COALESCE(\`Purpose\`, '') REGEXP '${MAILSHOT_PURPOSE_REGEX}'
OR COALESCE(\`DataSource\`, '') REGEXP '${ARTEFACT_REGEX}'
UNION ALL
SELECT 'mailshot_last_run' AS t, COUNT(*) AS c
FROM \`${DB_NAME}\`.\`mailshot_last_run\` lr
WHERE COALESCE(lr.\`data_source\`, '') REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(lr.\`recipient_key\`, '') REGEXP '^phase[0-9]'
OR lr.\`mailshot_id\` IN (
SELECT m.\`id\` FROM \`${DB_NAME}\`.\`mailshots\` m
WHERE COALESCE(m.\`Purpose\`, '') REGEXP '${MAILSHOT_PURPOSE_REGEX}'
OR COALESCE(m.\`DataSource\`, '') REGEXP '${ARTEFACT_REGEX}'
);"
echo
echo "Candidate rows to delete:"
"${MYSQL_CMD[@]}" -e "${candidate_counts_sql}" | while IFS=$'\t' read -r t c; do
printf ' %-26s %s\n' "${t}" "${c}"
done
if [[ "${INCLUDE_MAILSHOT_TEST}" -eq 1 ]]; then
c="$("${MYSQL_CMD[@]}" -e "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`mailshot_test\` WHERE COALESCE(\`Name\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') LIKE '%@example.test';")"
printf ' %-26s %s\n' "mailshot_test (optional)" "${c}"
else
echo " mailshot_test skipped (use --include-mailshot-test)"
fi
if [[ "${INCLUDE_CREDENTIALS}" -eq 1 ]]; then
c="$("${MYSQL_CMD[@]}" -e "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`mailshot_credentials\` WHERE COALESCE(\`smtp_host\`, '') LIKE 'smtp.test.%' OR COALESCE(\`imap_host\`, '') LIKE 'imap.test.%' OR COALESCE(\`smtp_user\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`imap_user\`, '') REGEXP '${ARTEFACT_REGEX}';")"
printf ' %-26s %s\n' "mailshot_credentials (optional)" "${c}"
else
echo " mailshot_credentials skipped (use --include-credentials)"
fi
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo
echo "Dry run complete. No changes made."
exit 0
fi
if [[ "${AUTO_YES}" -ne 1 ]]; then
echo
read -r -p "Proceed to delete only the candidate test artefact rows above? [y/N] " reply
if [[ ! "${reply}" =~ ^[Yy]$ ]]; then
echo "Aborted. No changes made."
exit 0
fi
fi
delete_sql="
DELETE FROM \`${DB_NAME}\`.\`mailshot_last_run\`
WHERE COALESCE(\`data_source\`, '') REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(\`recipient_key\`, '') REGEXP '^phase[0-9]'
OR \`mailshot_id\` IN (
SELECT \`id\` FROM (
SELECT m.\`id\`
FROM \`${DB_NAME}\`.\`mailshots\` m
WHERE COALESCE(m.\`Purpose\`, '') REGEXP '${MAILSHOT_PURPOSE_REGEX}'
OR COALESCE(m.\`DataSource\`, '') REGEXP '${ARTEFACT_REGEX}'
) x
);
DELETE FROM \`${DB_NAME}\`.\`mailshots\`
WHERE COALESCE(\`Purpose\`, '') REGEXP '${MAILSHOT_PURPOSE_REGEX}'
OR COALESCE(\`DataSource\`, '') REGEXP '${ARTEFACT_REGEX}';
DELETE FROM \`${DB_NAME}\`.\`mailshot_queries\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}';
DELETE FROM \`${DB_NAME}\`.\`mailshot_attachments\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(\`file_name\`, '') REGEXP '${ARTEFACT_REGEX}';
DELETE FROM \`${DB_NAME}\`.\`mailshot_pdf_assets\`
WHERE \`name\` REGEXP '${ARTEFACT_REGEX}'
OR COALESCE(\`file_name\`, '') REGEXP '${ARTEFACT_REGEX}';
"
"${MYSQL_CMD[@]}" -e "${delete_sql}"
if [[ "${INCLUDE_MAILSHOT_TEST}" -eq 1 ]]; then
"${MYSQL_CMD[@]}" -e "DELETE FROM \`${DB_NAME}\`.\`mailshot_test\` WHERE COALESCE(\`Name\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') LIKE '%@example.test';"
fi
if [[ "${INCLUDE_CREDENTIALS}" -eq 1 ]]; then
"${MYSQL_CMD[@]}" -e "DELETE FROM \`${DB_NAME}\`.\`mailshot_credentials\` WHERE COALESCE(\`smtp_host\`, '') LIKE 'smtp.test.%' OR COALESCE(\`imap_host\`, '') LIKE 'imap.test.%' OR COALESCE(\`smtp_user\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`imap_user\`, '') REGEXP '${ARTEFACT_REGEX}';"
fi
echo
echo "Cleanup complete. Remaining candidate row counts:"
"${MYSQL_CMD[@]}" -e "${candidate_counts_sql}" | while IFS=$'\t' read -r t c; do
printf ' %-26s %s\n' "${t}" "${c}"
done
if [[ "${INCLUDE_MAILSHOT_TEST}" -eq 1 ]]; then
c="$("${MYSQL_CMD[@]}" -e "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`mailshot_test\` WHERE COALESCE(\`Name\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`Email\`, '') LIKE '%@example.test';")"
printf ' %-26s %s\n' "mailshot_test (optional)" "${c}"
fi
if [[ "${INCLUDE_CREDENTIALS}" -eq 1 ]]; then
c="$("${MYSQL_CMD[@]}" -e "SELECT COUNT(*) FROM \`${DB_NAME}\`.\`mailshot_credentials\` WHERE COALESCE(\`smtp_host\`, '') LIKE 'smtp.test.%' OR COALESCE(\`imap_host\`, '') LIKE 'imap.test.%' OR COALESCE(\`smtp_user\`, '') REGEXP '${ARTEFACT_REGEX}' OR COALESCE(\`imap_user\`, '') REGEXP '${ARTEFACT_REGEX}';")"
printf ' %-26s %s\n' "mailshot_credentials (optional)" "${c}"
fi

View File

@ -57,6 +57,14 @@ if [[ ! -f "${PLUGIN_MAIN_FILE}" ]]; then
exit 1
fi
COMPOSER_JSON="${REPO_ROOT}/composer.json"
COMPOSER_LOCK="${REPO_ROOT}/composer.lock"
LOCAL_VENDOR_DIR="${REPO_ROOT}/vendor"
if [[ ! -f "${COMPOSER_JSON}" || ! -f "${COMPOSER_LOCK}" ]]; then
echo "Error: missing composer.json/composer.lock in ${REPO_ROOT}" >&2
exit 1
fi
load_env_value() {
local key="$1"
local line
@ -107,6 +115,8 @@ if [[ "${REMOTE_DIR_RAW}" = /* ]]; then
else
REMOTE_DIR="/home/${SSH_USER}/${REMOTE_DIR_RAW}"
fi
REMOTE_PARENT_DIR="$(dirname "${REMOTE_DIR}")"
REMOTE_VENDOR_DIR="${REMOTE_PARENT_DIR}/vendor"
CURRENT_VERSION="$(awk '
/Version:/ {
@ -153,13 +163,34 @@ fi
echo "Remote host: ${SSH_TARGET}:${SSH_PORT}"
echo "Source dir: ${SOURCE_DIR}"
echo "Deploy target: ${REMOTE_DIR}"
echo "Vendor target: ${REMOTE_VENDOR_DIR}"
if [[ "${DRY_RUN}" -eq 1 ]]; then
if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then
echo "Warning: ${LOCAL_VENDOR_DIR}/autoload.php not found. Run 'composer install --no-dev' before real deploy."
fi
else
if command -v composer >/dev/null 2>&1; then
echo "Installing/updating PHP dependencies from composer.lock..."
(
cd "${REPO_ROOT}"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
)
else
echo "composer not found on PATH; using existing ${LOCAL_VENDOR_DIR}."
fi
if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then
echo "Error: ${LOCAL_VENDOR_DIR}/autoload.php is missing. Install dependencies first (composer install --no-dev)." >&2
exit 1
fi
fi
ssh -p "${SSH_PORT}" \
-i "${SSH_KEY_PATH}" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
"${SSH_TARGET}" \
"mkdir -p '${REMOTE_DIR}'"
"mkdir -p '${REMOTE_DIR}' '${REMOTE_VENDOR_DIR}'"
RSYNC_SSH="ssh -p ${SSH_PORT} -i ${SSH_KEY_PATH} -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
RSYNC_ARGS=(
@ -179,6 +210,20 @@ rsync "${RSYNC_ARGS[@]}" \
"${SOURCE_DIR}/" \
"${SSH_TARGET}:${REMOTE_DIR}/"
# Keep root-level Composer dependencies in sync for plugin autoloading.
# Intentionally avoids --delete so we don't remove unrelated packages that may share this vendor dir.
VENDOR_RSYNC_ARGS=(
-avz
)
if [[ "${DRY_RUN}" -eq 1 ]]; then
VENDOR_RSYNC_ARGS+=(-n)
fi
rsync "${VENDOR_RSYNC_ARGS[@]}" \
-e "${RSYNC_SSH}" \
"${LOCAL_VENDOR_DIR}/" \
"${SSH_TARGET}:${REMOTE_VENDOR_DIR}/"
if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "Dry-run complete. No remote files were changed."
else

103
scripts/package_plugin.sh Executable file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PLUGIN_SLUG="${PLUGIN_SLUG:-feca_mailshots_plugin}"
SOURCE_DIR="${SOURCE_DIR:-${REPO_ROOT}/${PLUGIN_SLUG}}"
OUTPUT_DIR="${OUTPUT_DIR:-${REPO_ROOT}/dist}"
usage() {
cat <<'USAGE'
Usage: scripts/package_plugin.sh [--source /path/to/plugin] [--output /path/to/dist]
Creates a versioned, uploadable WordPress plugin zip from the plugin directory.
Defaults:
source: ./feca_mailshots_plugin
output: ./dist
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SOURCE_DIR="${2:-}"
shift 2
;;
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! -d "${SOURCE_DIR}" ]]; then
echo "Error: source directory not found: ${SOURCE_DIR}" >&2
exit 1
fi
PLUGIN_BASENAME="$(basename "${SOURCE_DIR}")"
PLUGIN_MAIN_FILE="${SOURCE_DIR}/${PLUGIN_BASENAME}.php"
if [[ ! -f "${PLUGIN_MAIN_FILE}" ]]; then
echo "Error: plugin main file not found: ${PLUGIN_MAIN_FILE}" >&2
exit 1
fi
if ! command -v zip >/dev/null 2>&1; then
echo "Error: 'zip' command is required but not installed." >&2
exit 1
fi
VERSION="$(awk '
/Version:/ {
line = $0
sub(/^.*Version:[[:space:]]*/, "", line)
if (match(line, /^[0-9]+\.[0-9]+\.[0-9]+/)) {
print substr(line, RSTART, RLENGTH)
exit
}
}
' "${PLUGIN_MAIN_FILE}")"
if [[ -z "${VERSION}" ]]; then
echo "Error: could not parse semantic version from ${PLUGIN_MAIN_FILE}" >&2
exit 1
fi
mkdir -p "${OUTPUT_DIR}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
STAGE_DIR="${TMP_DIR}/${PLUGIN_BASENAME}"
mkdir -p "${STAGE_DIR}"
rsync -a \
--delete \
--exclude '.git/' \
--exclude '.DS_Store' \
--exclude '.codex/' \
--exclude '.idea/' \
--exclude '.vscode/' \
--exclude 'node_modules/' \
"${SOURCE_DIR}/" "${STAGE_DIR}/"
ARCHIVE_PATH="${OUTPUT_DIR}/${PLUGIN_BASENAME}-${VERSION}.zip"
rm -f "${ARCHIVE_PATH}"
(
cd "${TMP_DIR}"
zip -qr "${ARCHIVE_PATH}" "${PLUGIN_BASENAME}"
)
echo "Created package: ${ARCHIVE_PATH}"

View File

@ -0,0 +1,191 @@
-- Renewal views for fenedgec_members
-- Applies current requirements from requirements/renewal_views.md
-- Safe to run repeatedly: drops and recreates all renewal views.
-- Drop in dependency order.
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_accounts_with_contacts`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_public_contact`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_fen_contact`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_contact_2`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_contact_1`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_pending`;
DROP VIEW IF EXISTS `fenedgec_members`.`renewal_members`;
CREATE VIEW `fenedgec_members`.`renewal_members` AS
SELECT
a.`id` AS `account_id`,
a.`name` AS `account_name`,
a.`account_audience` AS `account_audience`,
a.`account_no_of_members` AS `account_no_of_members`,
a.`account_type_id` AS `account_type_id`,
pat.`value` AS `account_type`,
a.`sector_id` AS `sector_id`,
ps.`value` AS `account_sector`,
a.`public_location_id` AS `public_location_id`,
ppl.`value` AS `public_location`
FROM `fenedgec_members`.`accounts` a
LEFT JOIN `fenedgec_members`.`picklist_account_type` pat
ON pat.`id` = a.`account_type_id`
LEFT JOIN `fenedgec_members`.`picklist_sector` ps
ON ps.`id` = a.`sector_id`
LEFT JOIN `fenedgec_members`.`picklist_public_location` ppl
ON ppl.`id` = a.`public_location_id`
WHERE a.`is_deleted` = 0
AND LOWER(TRIM(COALESCE(pat.`slug`, ''))) = 'member';
CREATE VIEW `fenedgec_members`.`renewal_pending` AS
SELECT
r.`id` AS `renewal_id`,
r.`account_id` AS `renewal_account_id`,
r.`renewal_year` AS `renewal_year`,
r.`status` AS `renewal_status`,
r.`selected` AS `renewal_selected`,
r.`payment_method` AS `renewal_payment_method`,
r.`payment_amount` AS `renewal_payment_amount`,
r.`payment_date` AS `renewal_payment_date`,
r.`created_at` AS `renewal_created_at`,
r.`updated_at` AS `renewal_updated_at`
FROM `fenedgec_members`.`renewals` r
WHERE LOWER(TRIM(COALESCE(r.`status`, ''))) = 'pending';
CREATE VIEW `fenedgec_members`.`renewal_contact_1` AS
SELECT
c.`id` AS `contact_1_contact_id`,
c.`account_id` AS `contact_1_account_id`,
TRIM(CONCAT_WS(' ', c.`first_name`, c.`middle_name`, c.`last_name`)) AS `contact_1_name`,
c.`title` AS `contact_1_position`,
c.`contact_address_1` AS `contact_1_address_1`,
c.`contact_address_2` AS `contact_1_address_2`,
c.`contact_town` AS `contact_1_town`,
c.`contact_county` AS `contact_1_county`,
c.`contact_postcode` AS `contact_1_postcode`,
c.`contact_email_1` AS `contact_1_email`,
c.`home_phone` AS `contact_1_phone`,
c.`mobile` AS `contact_1_mobile`
FROM `fenedgec_members`.`contacts` c
INNER JOIN (
SELECT `account_id`, MIN(`id`) AS `min_id`
FROM `fenedgec_members`.`contacts`
WHERE `is_deleted` = 0
AND `is_contact_1` = 1
GROUP BY `account_id`
) x
ON x.`account_id` = c.`account_id`
AND x.`min_id` = c.`id`
WHERE c.`is_deleted` = 0
AND c.`is_contact_1` = 1;
CREATE VIEW `fenedgec_members`.`renewal_contact_2` AS
SELECT
c.`id` AS `contact_2_contact_id`,
c.`account_id` AS `contact_2_account_id`,
TRIM(CONCAT_WS(' ', c.`first_name`, c.`middle_name`, c.`last_name`)) AS `contact_2_name`,
c.`title` AS `contact_2_position`,
c.`contact_address_1` AS `contact_2_address_1`,
c.`contact_address_2` AS `contact_2_address_2`,
c.`contact_town` AS `contact_2_town`,
c.`contact_county` AS `contact_2_county`,
c.`contact_postcode` AS `contact_2_postcode`,
c.`contact_email_1` AS `contact_2_email`,
c.`home_phone` AS `contact_2_phone`,
c.`mobile` AS `contact_2_mobile`
FROM `fenedgec_members`.`contacts` c
INNER JOIN (
SELECT `account_id`, MIN(`id`) AS `min_id`
FROM `fenedgec_members`.`contacts`
WHERE `is_deleted` = 0
AND `is_contact_2` = 1
GROUP BY `account_id`
) x
ON x.`account_id` = c.`account_id`
AND x.`min_id` = c.`id`
WHERE c.`is_deleted` = 0
AND c.`is_contact_2` = 1;
CREATE VIEW `fenedgec_members`.`renewal_fen_contact` AS
SELECT
c.`id` AS `fen_contact_contact_id`,
c.`account_id` AS `fen_contact_account_id`,
TRIM(CONCAT_WS(' ', c.`first_name`, c.`middle_name`, c.`last_name`)) AS `fen_contact_name`,
c.`contact_email_1` AS `fen_contact_email`
FROM `fenedgec_members`.`contacts` c
INNER JOIN (
SELECT `account_id`, MIN(`id`) AS `min_id`
FROM `fenedgec_members`.`contacts`
WHERE `is_deleted` = 0
AND `is_fen_1` = 1
GROUP BY `account_id`
) x
ON x.`account_id` = c.`account_id`
AND x.`min_id` = c.`id`
WHERE c.`is_deleted` = 0
AND c.`is_fen_1` = 1;
CREATE VIEW `fenedgec_members`.`renewal_public_contact` AS
SELECT
c.`id` AS `public_contact_contact_id`,
c.`account_id` AS `public_contact_account_id`,
TRIM(CONCAT_WS(' ', c.`first_name`, c.`middle_name`, c.`last_name`)) AS `public_contact_name`,
c.`public_phone` AS `public_contact_public_phone`,
c.`public_email` AS `public_contact_public_email`
FROM `fenedgec_members`.`contacts` c
INNER JOIN (
SELECT `account_id`, MIN(`id`) AS `min_id`
FROM `fenedgec_members`.`contacts`
WHERE `is_deleted` = 0
AND `is_public_contact` = 1
GROUP BY `account_id`
) x
ON x.`account_id` = c.`account_id`
AND x.`min_id` = c.`id`
WHERE c.`is_deleted` = 0
AND c.`is_public_contact` = 1;
CREATE VIEW `fenedgec_members`.`renewal_accounts_with_contacts` AS
SELECT
rm.*,
rp.*,
rc1.`contact_1_contact_id`,
rc1.`contact_1_account_id`,
rc1.`contact_1_name`,
rc1.`contact_1_position`,
rc1.`contact_1_address_1`,
rc1.`contact_1_address_2`,
rc1.`contact_1_town`,
rc1.`contact_1_county`,
rc1.`contact_1_postcode`,
rc1.`contact_1_email`,
rc1.`contact_1_phone`,
rc1.`contact_1_mobile`,
rc2.`contact_2_contact_id`,
rc2.`contact_2_account_id`,
rc2.`contact_2_name`,
rc2.`contact_2_position`,
rc2.`contact_2_address_1`,
rc2.`contact_2_address_2`,
rc2.`contact_2_town`,
rc2.`contact_2_county`,
rc2.`contact_2_postcode`,
rc2.`contact_2_email`,
rc2.`contact_2_phone`,
rc2.`contact_2_mobile`,
rcf.`fen_contact_contact_id`,
rcf.`fen_contact_account_id`,
rcf.`fen_contact_name`,
rcf.`fen_contact_email`,
rpc.`public_contact_contact_id`,
rpc.`public_contact_account_id`,
rpc.`public_contact_name`,
rpc.`public_contact_public_phone`,
rpc.`public_contact_public_email`
FROM `fenedgec_members`.`renewal_pending` rp
INNER JOIN `fenedgec_members`.`renewal_members` rm
ON rm.`account_id` = rp.`renewal_account_id`
LEFT JOIN `fenedgec_members`.`renewal_contact_1` rc1
ON rc1.`contact_1_account_id` = rm.`account_id`
LEFT JOIN `fenedgec_members`.`renewal_contact_2` rc2
ON rc2.`contact_2_account_id` = rm.`account_id`
LEFT JOIN `fenedgec_members`.`renewal_fen_contact` rcf
ON rcf.`fen_contact_account_id` = rm.`account_id`
LEFT JOIN `fenedgec_members`.`renewal_public_contact` rpc
ON rpc.`public_contact_account_id` = rm.`account_id`;

View File

@ -14,7 +14,13 @@ Or manually:
scripts/run_fixture_server.sh start
npm install
npx playwright install chromium
E2E_BASE_URL=http://127.0.0.1:8080 npx playwright test
E2E_BASE_URL=http://127.0.0.1:8092 npx playwright test
```
Remote admin mode (uses `credentials/.env` `E2E_REMOTE_ADMIN*` vars):
```bash
E2E_REMOTE_ADMIN=1 tests/e2e/run.sh
```
## Specs
@ -27,6 +33,19 @@ E2E_BASE_URL=http://127.0.0.1:8080 npx playwright test
- datasource-driven token/field population, message/PDF overlay editor flows, create mailshot.
- `run-test-pages.spec.mjs`
- Run/Test page load checks and API failure-path assertions (`run_mailshot` invalid id, `send_test` blank email).
- `download-pdf-renewal.spec.mjs`
- Optional live-data e2e for `renewal_accounts_with_contacts` + `formats/renewals_v4.html`, asserts merged and ZIP downloads both succeed.
## Renewal Dataset E2E
Run this targeted dataset test with:
```bash
E2E_RENEWAL_ENABLE=1 \
E2E_RENEWAL_DATASOURCE=renewal_accounts_with_contacts \
E2E_BASE_URL=http://127.0.0.1:8092 \
npx playwright test tests/e2e/specs/download-pdf-renewal.spec.mjs
```
## Notes

116
tests/e2e/global-setup.mjs Normal file
View File

@ -0,0 +1,116 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
import { chromium } from '@playwright/test';
function parseDotEnvFile(envPath) {
if (!fs.existsSync(envPath)) {
return {};
}
const out = {};
const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/);
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#')) {
continue;
}
const idx = line.indexOf('=');
if (idx <= 0) {
continue;
}
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
export default async function globalSetup(config) {
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..', '..');
const dotEnv = parseDotEnvFile(path.join(root, 'credentials', '.env'));
const remoteEnabled = String(process.env.E2E_REMOTE_ADMIN || dotEnv.E2E_REMOTE_ADMIN || '') === '1';
const baseURL = String(
remoteEnabled
? (
process.env.E2E_BASE_URL
|| process.env.E2E_REMOTE_ADMIN_BASE_URL
|| dotEnv.E2E_REMOTE_ADMIN_BASE_URL
|| config?.projects?.[0]?.use?.baseURL
|| config?.use?.baseURL
|| 'http://127.0.0.1:8080'
)
: (
config?.projects?.[0]?.use?.baseURL
|| config?.use?.baseURL
|| process.env.E2E_BASE_URL
|| 'http://127.0.0.1:8080'
)
);
let fixturePort = process.env.FIXTURE_PORT || '';
if (!fixturePort) {
try {
const parsed = new URL(baseURL);
fixturePort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
} catch {
fixturePort = '8080';
}
}
if (remoteEnabled) {
const user = process.env.E2E_REMOTE_ADMIN_USER || dotEnv.E2E_REMOTE_ADMIN_USER || '';
const pass = process.env.E2E_REMOTE_ADMIN_PASS || dotEnv.E2E_REMOTE_ADMIN_PASS || '';
if (!user || !pass) {
throw new Error('E2E remote admin mode requires E2E_REMOTE_ADMIN_USER and E2E_REMOTE_ADMIN_PASS.');
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/wp-login.php', { waitUntil: 'domcontentloaded' });
await page.fill('#user_login', user);
await page.fill('#user_pass', pass);
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.click('#wp-submit')
]);
const currentUrl = page.url();
if (currentUrl.includes('/wp-login.php')) {
throw new Error('Remote WordPress login failed. Check E2E_REMOTE_ADMIN credentials.');
}
const authPath = path.join(root, 'working', 'e2e-remote-auth.json');
fs.mkdirSync(path.dirname(authPath), { recursive: true });
await context.storageState({ path: authPath });
await context.close();
await browser.close();
process.env.PLAYWRIGHT_AUTH_STATE = authPath;
return;
}
const tunnelScript = path.join(root, 'scripts', 'mysql_tunnel.sh');
const script = path.join(root, 'scripts', 'run_fixture_server.sh');
execFileSync(tunnelScript, ['start'], {
cwd: root,
stdio: 'inherit',
env: {
...process.env
}
});
execFileSync(script, ['start'], {
cwd: root,
stdio: 'inherit',
env: {
...process.env,
FIXTURE_PORT: String(fixturePort)
}
});
}

View File

@ -4,10 +4,37 @@ set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURE_PORT="${FIXTURE_PORT:-8092}"
BASE_URL="${E2E_BASE_URL:-http://127.0.0.1:${FIXTURE_PORT}}"
ENV_FILE="${ROOT_DIR}/credentials/.env"
load_env_value() {
local key="$1"
local line
line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)"
line="${line#*=}"
line="${line%\"}"
line="${line#\"}"
line="${line%\'}"
line="${line#\'}"
printf '%s' "${line}"
}
cd "${ROOT_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
E2E_REMOTE_ADMIN="${E2E_REMOTE_ADMIN:-$(load_env_value E2E_REMOTE_ADMIN)}"
E2E_REMOTE_ADMIN_BASE_URL="${E2E_REMOTE_ADMIN_BASE_URL:-$(load_env_value E2E_REMOTE_ADMIN_BASE_URL)}"
E2E_REMOTE_ADMIN_USER="${E2E_REMOTE_ADMIN_USER:-$(load_env_value E2E_REMOTE_ADMIN_USER)}"
E2E_REMOTE_ADMIN_PASS="${E2E_REMOTE_ADMIN_PASS:-$(load_env_value E2E_REMOTE_ADMIN_PASS)}"
export E2E_REMOTE_ADMIN E2E_REMOTE_ADMIN_BASE_URL E2E_REMOTE_ADMIN_USER E2E_REMOTE_ADMIN_PASS
fi
if [[ "${E2E_REMOTE_ADMIN:-0}" = "1" && -n "${E2E_REMOTE_ADMIN_BASE_URL:-}" && -z "${E2E_BASE_URL:-}" ]]; then
BASE_URL="${E2E_REMOTE_ADMIN_BASE_URL}"
fi
if [[ "${E2E_REMOTE_ADMIN:-0}" != "1" ]]; then
FIXTURE_PORT="${FIXTURE_PORT}" scripts/run_fixture_server.sh start >/dev/null
fi
if [[ ! -d node_modules ]]; then
npm install

View File

@ -0,0 +1,175 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { adminPath, apiList, apiPost, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renewalsTemplate = fs.readFileSync(path.resolve(__dirname, '../../../formats/renewals_v4.html'), 'utf8');
const dsName = process.env.E2E_RENEWAL_DATASOURCE || 'renewal_accounts_with_contacts';
const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)';
const allowCreate = process.env.E2E_RENEWAL_ALLOW_CREATE === '1';
const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1';
test.describe('renewal dataset: download pdf e2e', () => {
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
const clickAndExpectDownload = async (page, buttonName, expectedFilenamePart) => {
page.once('dialog', async (dialog) => {
await dialog.accept();
});
const nextMatchingDownload = async () => {
const deadline = Date.now() + 240000;
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const download = await page.waitForEvent('download', { timeout: remaining }).catch(() => null);
if (!download) {
break;
}
if (download.suggestedFilename().includes(expectedFilenamePart)) {
return download;
}
}
return null;
};
const downloadPromise = nextMatchingDownload();
await page.getByRole('button', { name: buttonName }).click();
let downloaded;
try {
downloaded = await downloadPromise;
} catch {
const critical = page.getByText('There has been a critical error on this website.');
if (await critical.count()) {
throw new Error(`Download action "${buttonName}" reached a WordPress critical error page.`);
}
const failBanner = page.locator('strong', { hasText: 'PDF generation failed.' });
if (await failBanner.count()) {
const msg = (await page.locator('.wrap').innerText()).replace(/\s+/g, ' ').trim();
throw new Error(`Download action "${buttonName}" failed in UI: ${msg}`);
}
throw new Error(`Download action "${buttonName}" timed out waiting for file download.`);
}
if (!downloaded) {
throw new Error(`Download action "${buttonName}" did not produce expected file "${expectedFilenamePart}".`);
}
expect(downloaded.suggestedFilename()).toContain(expectedFilenamePart);
const outPath = await downloaded.path();
expect(outPath).toBeTruthy();
const outSize = fs.statSync(outPath).size;
expect(outSize).toBeGreaterThan(1000);
};
const resolveMailshot = async (request) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const purpose = `e2e_renewal_download_${uniq}`;
const dsCreatedName = `e2e_renewal_ds_${uniq}`;
let mailshotId = '';
let effectiveDsName = dsName;
let createdMailshot = false;
const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api');
expect(mailshotList.ok).toBeTruthy();
const existing = (mailshotList.items || []).find(
(row) => String(row.Purpose || '') === mailshotPurpose
);
if (existing) {
mailshotId = String(existing.id || '');
effectiveDsName = String(existing.DataSource || '').trim();
if (effectiveDsName === '') {
throw new Error(`Mailshot "${mailshotPurpose}" has no DataSource.`);
}
} else {
const dsList = await apiList(request, 'feca_mailshots_data_sources_api');
expect(dsList.ok).toBeTruthy();
const dsExists = (dsList.items || []).some((row) => String(row.name || '') === dsName);
if (!dsExists) {
if (!allowCreate) {
throw new Error(`Required data source not found: ${dsName}`);
}
await ensureDataSource(request, dsCreatedName, dsName);
effectiveDsName = dsCreatedName;
}
if (!allowCreate) {
throw new Error(`Required mailshot not found: ${mailshotPurpose}`);
}
const saved = await ensureMailshot(request, {
Purpose: purpose,
DataSource: effectiveDsName,
CC: '',
BCC: '',
Subject: 'Renewal for {{ account_name }}',
Message: '',
PDFAttachment: renewalsTemplate,
AttachmentNames: '[]',
PDFFilenameDerivedFrom: 'account_name',
RecipientEmailField: '',
ReplyTo: ''
});
mailshotId = String(saved.id || '');
createdMailshot = true;
}
expect(mailshotId).not.toBe('');
const dsListFinal = await apiList(request, 'feca_mailshots_data_sources_api');
expect(dsListFinal.ok).toBeTruthy();
const effectiveDsExists = (dsListFinal.items || []).some((row) => String(row.name || '') === effectiveDsName);
expect(effectiveDsExists).toBeTruthy();
const render = await apiPost(request, 'feca_mailshots_test_api', 'render_test', {
mailshot_id: mailshotId,
recipient_index: '0'
});
expect(render.ok).toBeTruthy();
expect(String(render?.rendered?.pdf_attachment || '')).toContain('Membership Renewal');
return {
mailshotId,
cleanup: async () => {
await cleanupByNames(request, {
dataSourceNames: [dsCreatedName],
mailshotPurposes: createdMailshot ? [purpose] : []
});
}
};
};
test('downloads merged PDF for renewal_accounts_with_contacts', async ({ page, request }) => {
const resolved = await resolveMailshot(request);
try {
await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(resolved.mailshotId)}`);
await expect(page.getByRole('heading', { name: 'Download PDF' })).toBeVisible();
await page.locator('#dp_mailshot_id').selectOption(resolved.mailshotId);
await clickAndExpectDownload(page, 'Download Merged PDF', '_merged.pdf');
} finally {
try {
await resolved.cleanup();
} catch {
// best effort cleanup
}
}
});
test('downloads ZIP of PDFs for renewal_accounts_with_contacts', async ({ page, request }) => {
const resolved = await resolveMailshot(request);
try {
await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(resolved.mailshotId)}`);
await expect(page.getByRole('heading', { name: 'Download PDF' })).toBeVisible();
await page.locator('#dp_mailshot_id').selectOption(resolved.mailshotId);
await clickAndExpectDownload(page, 'Download ZIP of PDFs', '_pdfs.zip');
} finally {
try {
await resolved.cleanup();
} catch {
// best effort cleanup
}
}
});
});

View File

@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { adminPath, apiList } from './helpers.mjs';
const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1';
const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)';
test.describe('download pdf ui feedback', () => {
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
test('download page has no js syntax errors and shows progress state on submit', async ({ page, request }) => {
const jsErrors = [];
page.on('pageerror', (err) => {
jsErrors.push(String(err && err.message ? err.message : err));
});
const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api');
expect(mailshotList.ok).toBeTruthy();
const existing = (mailshotList.items || []).find(
(row) => String(row.Purpose || '') === mailshotPurpose
);
expect(existing).toBeTruthy();
const mailshotId = String(existing.id || '');
expect(mailshotId).not.toBe('');
await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(mailshotId)}`);
await expect(page.getByRole('heading', { name: 'Download PDF' })).toBeVisible();
await page.waitForTimeout(250);
expect(jsErrors, `Page JS errors: ${jsErrors.join(' | ')}`).toEqual([]);
await page.evaluate(() => {
const form = document.getElementById('feca-download-pdf-form');
if (!form) {
throw new Error('Download form not found.');
}
form.addEventListener('submit', (ev) => {
ev.preventDefault();
});
});
page.once('dialog', async (dialog) => {
await dialog.accept();
});
await page.getByRole('button', { name: 'Download Merged PDF' }).click();
await expect(page.locator('#feca-download-progress')).toBeVisible();
await expect(page.locator('#feca-download-merged-btn')).toBeDisabled();
await expect(page.locator('#feca-download-zip-btn')).toBeEnabled();
});
});

View File

@ -1,6 +1,57 @@
import { test, expect } from '@playwright/test';
import { adminPath, ensureDataSource, cleanupByNames } from './helpers.mjs';
const setJoditHtml = async (page, html) => {
await page.evaluate((nextHtml) => {
const host = /** @type {any} */ (document.getElementById('ms-template-jodit'));
const instances = (window.Jodit && window.Jodit.instances) ? window.Jodit.instances : null;
let jodit = host && host.jodit ? host.jodit : null;
if (!jodit && instances) {
jodit = instances['ms-template-jodit'] || instances.ms_template_jodit || null;
}
if (!jodit && instances && typeof instances === 'object') {
const vals = Array.isArray(instances) ? instances : Object.values(instances);
jodit = vals.find((x) => x && x.container && x.container.isConnected) || null;
}
if (!jodit) {
throw new Error('Jodit instance not available');
}
if (typeof jodit.setEditorValue === 'function') {
jodit.setEditorValue(nextHtml);
} else {
jodit.value = nextHtml;
}
if (typeof jodit.synchronizeValues === 'function') {
jodit.synchronizeValues();
}
}, html);
};
const setJoditMode = async (page, modeName) => {
await page.evaluate((nextModeName) => {
const host = /** @type {any} */ (document.getElementById('ms-template-jodit'));
const instances = (window.Jodit && window.Jodit.instances) ? window.Jodit.instances : null;
let jodit = host && host.jodit ? host.jodit : null;
if (!jodit && instances) {
jodit = instances['ms-template-jodit'] || instances.ms_template_jodit || null;
}
if (!jodit && instances && typeof instances === 'object') {
const vals = Array.isArray(instances) ? instances : Object.values(instances);
jodit = vals.find((x) => x && x.container && x.container.isConnected) || null;
}
if (!jodit) {
throw new Error('Jodit instance not available');
}
const modeValue = nextModeName === 'wysiwyg'
? window.Jodit.MODE_WYSIWYG
: window.Jodit.MODE_SOURCE;
if (typeof jodit.setMode !== 'function') {
throw new Error('Jodit setMode is unavailable');
}
jodit.setMode(modeValue);
}, modeName);
};
test('mailshots editor: datasource-driven options + overlay editors + create', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ms_ds_${uniq}`;
@ -26,50 +77,18 @@ test('mailshots editor: datasource-driven options + overlay editors + create', a
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await page.evaluate(() => {
const html = '<style>.red-note{color:red;}</style><div class="red-note">Hello <strong>bold</strong> {{ contacts.id }}</div>';
const ed = window.tinymce && window.tinymce.get ? window.tinymce.get('ms-template-html') : null;
const raw = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('ms-template-html'));
if (ed) {
ed.setContent(html);
ed.save();
} else if (raw) {
raw.value = html;
}
});
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/<style[^>]*>[\s\S]*\.red-note\s*\{[^}]*color:\s*red;?[^}]*\}[\s\S]*<\/style>/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/<strong>bold<\/strong>/);
await page.locator('#ms-template-mode-visual').click();
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/data-mce-type=["']bookmark["']/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/mce_SELRES_(?:start|end)/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/<style[^>]*>[\s\S]*<span[\s\S]*<\/style>/i);
const formattedSource = `<style>
.red-note { color: red; }
</style>
<div class="red-note">
hello
</div>`;
await page.locator('#ms-template-code').fill(formattedSource);
await page.locator('#ms-template-mode-visual').click();
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/<style>\s*\n\s*\.red-note\s*\{\s*color:\s*red;\s*\}\s*\n<\/style>/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s*\n<div class="red-note">/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s+hello\s*\n<\/div>/i);
await expect(page.locator('.jodit-container')).toBeVisible();
await setJoditHtml(page, '<style>.red-note{color:red;}</style><div class="red-note">Hello <strong>bold</strong> {{ contacts.id }}</div>');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms_message')).toContainText('.red-note{color:red;}');
await expect(page.locator('#ms_message')).toContainText('class="red-note"');
await expect(page.locator('#ms_message')).toContainText('<strong>bold</strong>');
await expect(page.locator('#ms_message')).toHaveValue(/\.red-note\{color:red;\}/);
await expect(page.locator('#ms_message')).toHaveValue(/class="red-note"/);
await expect(page.locator('#ms_message')).toHaveValue(/<strong>bold<\/strong>/);
await page.locator('#ms-edit-pdf').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await page.locator('#ms-template-mode-code').click();
await page.locator('#ms-template-code').fill('<p>PDF {{ contacts.id }}</p>');
await setJoditHtml(page, '<p>PDF {{ contacts.id }}</p>');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms_pdfa')).toHaveValue(/<p>PDF \{\{ contacts\.id \}\}<\/p>/);
await page.locator('#ms_email_field').evaluate((el) => {
const select = /** @type {HTMLSelectElement} */ (el);
@ -90,3 +109,107 @@ test('mailshots editor: datasource-driven options + overlay editors + create', a
}
}
});
test('mailshots editor: message save closes modal and persists on reopen', async ({ page }) => {
const html = '<style>.red-note{color:red;}</style>\n<div class="red-note">Hello <strong>bold</strong> {{ contacts.id }}</div>';
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, html);
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await expect(page.locator('#ms_message')).toHaveValue(/<style>\.red-note\{color:red;\}<\/style>/);
await expect(page.locator('#ms_message')).toHaveValue(/<div class="red-note">Hello <strong>bold<\/strong> {{ contacts\.id }}<\/div>/);
await expect.poll(async () => {
const text = (await page.locator('#ms-message-meta').innerText()).trim();
const match = text.match(/^(\d+)\s+chars$/);
return match ? Number(match[1]) : 0;
}).toBeGreaterThan(0);
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
const reopened = await page.evaluate(() => {
const host = /** @type {any} */ (document.getElementById('ms-template-jodit'));
const instances = (window.Jodit && window.Jodit.instances) ? window.Jodit.instances : null;
let jodit = host && host.jodit ? host.jodit : null;
if (!jodit && instances) {
jodit = instances['ms-template-jodit'] || instances.ms_template_jodit || null;
}
if (!jodit && instances && typeof instances === 'object') {
const vals = Array.isArray(instances) ? instances : Object.values(instances);
jodit = vals.find((x) => x && x.container && x.container.isConnected) || null;
}
if (!jodit) {
throw new Error('Jodit instance not available');
}
if (typeof jodit.synchronizeValues === 'function') {
jodit.synchronizeValues();
}
if (typeof jodit.getEditorValue === 'function') {
return String(jodit.getEditorValue() || '');
}
return String(jodit.value || '');
});
expect(reopened).toContain('.red-note{color:red;}');
expect(reopened).toContain('class="red-note"');
expect(reopened).toContain('<strong>bold</strong>');
});
test('mailshots editor: save works in WYSIWYG mode with a single click', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditMode(page, 'wysiwyg');
await setJoditHtml(page, '<p>WYSIWYG body {{ contacts_id }}</p>');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await expect(page.locator('#ms_message')).toHaveValue(/WYSIWYG body/);
});
test('mailshots editor: allows save without recipient field and shows warning', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ms_ds_norec_${uniq}`;
const purpose = `e2e_mailshot_norec_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill(purpose);
await page.locator('#ms_ds').selectOption({ label: dsName });
await page.locator('#ms_subject').fill('No recipient email field');
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, '<p>PDF-only workflow {{ contacts.id }}</p>');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-recipient-warning')).toBeVisible();
await expect(page.locator('#ms-recipient-warning')).toContainText('cannot be used to send a mailshot');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('cell', { name: purpose })).toBeVisible();
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
} catch {
// best effort
}
}
});

View File

@ -0,0 +1,260 @@
import { test, expect } from '@playwright/test';
import { adminPath } from './helpers.mjs';
import { ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const invoicingHtml = fs.readFileSync(path.resolve(__dirname, '../../../samples/invoicing.html'), 'utf8');
const setJoditHtml = async (page, html) => {
await page.evaluate((nextHtml) => {
const host = /** @type {any} */ (document.getElementById('ms-template-jodit'));
const instances = (window.Jodit && window.Jodit.instances) ? window.Jodit.instances : null;
let jodit = host && host.jodit ? host.jodit : null;
if (!jodit && instances) {
jodit = instances['ms-template-jodit'] || instances.ms_template_jodit || null;
}
if (!jodit && instances && typeof instances === 'object') {
const vals = Array.isArray(instances) ? instances : Object.values(instances);
jodit = vals.find((x) => x && x.container && x.container.isConnected) || null;
}
if (!jodit) {
throw new Error('Jodit instance not available');
}
if (typeof jodit.setEditorValue === 'function') {
jodit.setEditorValue(nextHtml);
} else {
jodit.value = nextHtml;
}
if (typeof jodit.synchronizeValues === 'function') {
jodit.synchronizeValues();
}
}, html);
};
test('regression: mailshot message modal saves and closes on first click', async ({ page }) => {
const longBody = Array.from({ length: 120 }, (_, i) => `<p>Line ${i + 1}</p>`).join('');
const html = `<style>body{font-family:Cambria, serif;}</style><div class="regression-one-click">One click save marker</div>${longBody}<div id="tail-marker">Tail marker</div>`;
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, html);
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await expect(page.locator('#ms_message')).toHaveValue(/regression-one-click/);
await expect(page.locator('#ms_message')).toHaveValue(/tail-marker/);
await expect(page.locator('#ms-message-snippet')).toContainText('One click save marker');
});
test('regression: message editor single-save with invoicing template (scroll case)', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, invoicingHtml);
await page.evaluate(() => {
const host = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('ms-template-jodit'));
if (host) {
host.scrollTop = host.scrollHeight;
host.focus();
}
const modal = document.getElementById('ms-template-modal');
if (!modal) { return; }
const areas = modal.querySelectorAll('textarea');
areas.forEach((ta) => {
const el = /** @type {HTMLTextAreaElement} */ (ta);
el.scrollTop = el.scrollHeight;
el.focus();
});
});
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await expect(page.locator('#ms_message')).toHaveValue(/invoice-header/);
await expect(page.locator('#ms_message')).toHaveValue(/Registered Charity No\. 293020/);
});
test('regression: message editor manual source edit saves on first click', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, invoicingHtml);
await page.evaluate(() => {
const modal = document.getElementById('ms-template-modal');
if (!modal) { return; }
const sourceAreas = Array.from(modal.querySelectorAll('textarea'))
.filter((el) => el.offsetParent !== null);
const source = sourceAreas[sourceAreas.length - 1] || null;
if (!source) { return; }
source.scrollTop = source.scrollHeight;
source.focus();
source.setSelectionRange(source.value.length, source.value.length);
source.value = `${source.value}\n<!-- manual-edit-marker -->`;
source.dispatchEvent(new Event('input', { bubbles: true }));
source.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await expect(page.locator('#ms_message')).toHaveValue(/manual-edit-marker/);
await expect(page.locator('#ms_message')).toHaveValue(/invoice-header/);
});
test('regression: mailshot validation failure keeps entered values in modal', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill('validation draft mailshot');
await page.locator('#ms_subject').fill('');
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, '<p>Draft body should survive validation failure</p>');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await page.locator('#mailshot-editor button[type="submit"]').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await expect(page.locator('#mailshot-editor')).toContainText('Please fix the following:');
await expect(page.locator('#mailshot-editor')).toContainText('Subject is required.');
await expect(page.locator('.wrap')).not.toContainText('Mailshot action failed.');
await expect(page.locator('#ms_purpose')).toHaveValue('validation draft mailshot');
await expect(page.locator('#ms_message')).toHaveValue(/Draft body should survive validation failure/);
});
test('regression: editing existing mailshot allows empty message for pdf-only workflow', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ms_edit_ds_${uniq}`;
const purpose = `e2e_mailshot_edit_${uniq}`;
const editedPurpose = `${purpose}_edited`;
let mailshotId = '';
try {
await ensureDataSource(request, dsName, 'contacts');
const saved = await ensureMailshot(request, {
Purpose: purpose,
DataSource: dsName,
CC: '',
BCC: '',
Subject: 'Original subject',
Message: '<p>Original message</p>',
PDFAttachment: '',
AttachmentNames: '[]',
PDFFilenameDerivedFrom: '',
RecipientEmailField: 'contacts.contact_email_1',
ReplyTo: ''
});
mailshotId = String(saved.id || '');
await page.goto(`${adminPath('feca-mailshots-mailshots')}&edit_id=${encodeURIComponent(mailshotId)}`);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill(editedPurpose);
await page.locator('#ms_subject').fill('Edited subject');
await page.locator('#ms_message').fill('');
await page.locator('#mailshot-editor button[type="submit"]').click();
await expect(page.locator('#ms-editor-modal')).toBeHidden();
await expect(page.getByRole('cell', { name: editedPurpose })).toBeVisible();
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose, editedPurpose] });
} catch {
// best effort
}
}
});
test('regression: attachments new button opens modal editor', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-attachments'));
await expect(page.locator('h1', { hasText: 'Attachments' })).toBeVisible();
await page.locator('#att-open-new').click();
await expect(page.locator('#att-editor-modal')).toBeVisible();
await expect(page.locator('#att-editor-form')).toBeVisible();
await expect(page.locator('#att-editor-id')).toHaveValue('');
});
test('regression: attachments upload populates filename and failed save preserves data', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-attachments'));
await expect(page.locator('h1', { hasText: 'Attachments' })).toBeVisible();
await page.locator('#att-open-new').click();
await expect(page.locator('#att-editor-modal')).toBeVisible();
await page.locator('#att_name').fill('draft_attachment');
await page.setInputFiles('#att_file_upload', {
name: 'sample-attachment.txt',
mimeType: 'text/plain',
buffer: Buffer.from('attachment regression sample', 'utf8')
});
await expect(page.locator('#att_file_name')).toHaveValue('sample-attachment.txt');
await page.locator('#att_name').fill('');
await page.getByRole('button', { name: 'Create Attachment' }).click();
await expect(page.locator('#att-editor-modal')).toBeVisible();
await expect(page.locator('#att-editor-form')).toContainText('Please fix the following:');
await expect(page.locator('#att-editor-form')).toContainText('name is required.');
await expect(page.locator('#att_file_name')).toHaveValue('sample-attachment.txt');
await expect(page.locator('#att_base64')).not.toHaveValue('');
});
test('regression: pdf assets new button opens modal editor', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-pdf-assets'));
await expect(page.locator('h1', { hasText: 'PDF Assets' })).toBeVisible();
await page.locator('#pdf-open-new').click();
await expect(page.locator('#pdf-editor-modal')).toBeVisible();
await expect(page.locator('#pdf-editor-form')).toBeVisible();
await expect(page.locator('#pdf-editor-id')).toHaveValue('');
});
test('regression: pdf assets failed save keeps modal values and shows inline errors', async ({ page }) => {
await page.goto(adminPath('feca-mailshots-pdf-assets'));
await expect(page.locator('h1', { hasText: 'PDF Assets' })).toBeVisible();
await page.locator('#pdf-open-new').click();
await expect(page.locator('#pdf-editor-modal')).toBeVisible();
await page.locator('#pdf_name').fill('draft_pdf_asset');
await page.locator('#pdf_width').fill('');
await page.locator('#pdf_height').fill('');
await page.locator('#pdf_file_name').fill('');
await page.setInputFiles('#pdf_file_upload', {
name: 'sample-asset.png',
mimeType: 'image/png',
buffer: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00])
});
await expect(page.locator('#pdf_file_name')).toHaveValue('sample-asset.png');
await page.locator('#pdf_file_name').fill('');
await page.getByRole('button', { name: 'Create PDF Asset' }).click();
await expect(page.locator('#pdf-editor-modal')).toBeVisible();
await expect(page.locator('#pdf-editor-form')).toContainText('Please fix the following:');
await expect(page.locator('#pdf-editor-form')).toContainText('file_name is required.');
await expect(page.locator('#pdf-editor-form')).toContainText('width_mm and height_mm must be > 0.');
await expect(page.locator('#pdf_name')).toHaveValue('draft_pdf_asset');
await expect(page.locator('#pdf_base64')).not.toHaveValue('');
});

View File

@ -8,13 +8,13 @@ test('run/test pages: load and failure-path API assertions', async ({ page, requ
try {
await ensureDataSource(request, dsName, 'contacts');
await ensureMailshot(request, {
const saved = await ensureMailshot(request, {
Purpose: purpose,
DataSource: dsName,
CC: '',
BCC: '',
Subject: 'Subj {{ contacts.id }}',
Message: '<p>Hi {{ contacts.id }}</p>',
Subject: 'Subj {{ contacts_id }}',
Message: '<p>Hi {{ contacts_id }}</p>',
PDFAttachment: '<p>PDF</p>',
AttachmentNames: '[]',
PDFFilenameDerivedFrom: '',
@ -22,12 +22,21 @@ test('run/test pages: load and failure-path API assertions', async ({ page, requ
ReplyTo: ''
});
await page.goto(adminPath('feca-mailshots-run'));
await page.goto(`${adminPath('feca-mailshots-run')}&mailshot_id=${encodeURIComponent(String(saved.id || ''))}`);
await expect(page.getByRole('heading', { name: 'Run Mailshot' })).toBeVisible();
await expect(page.getByText('Recipient rows:')).toBeVisible();
await expect(page.getByRole('button', { name: 'Load' })).toHaveCount(0);
await page.goto(adminPath('feca-mailshots-test'));
await expect(page.getByRole('heading', { name: 'Mailshot Test' })).toBeVisible();
const renderTest = await apiPost(request, 'feca_mailshots_test_api', 'render_test', {
mailshot_id: String(saved.id || ''),
recipient_index: '0'
});
expect(renderTest.ok).toBeTruthy();
expect(String(renderTest?.rendered?.subject || '')).toContain('Subj');
const runInvalid = await apiPost(request, 'feca_mailshots_run_api', 'run_mailshot', { mailshot_id: '0' });
expect(runInvalid.ok).toBeFalsy();
expect(String((runInvalid.errors || []).join(' | ') || runInvalid.error || '')).toContain('Missing mail credentials');

View File

@ -34,5 +34,7 @@ $container->get(FecaMailshots\Admin\SetupAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register();
$container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register();
$container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\DownloadPdfAdminPage::class)->register();
return ['container' => $container, 'wp' => $wp];

View File

@ -16,6 +16,8 @@ $map = [
'feca-mailshots-profile' => FecaMailshots\Admin\ProfileAdminPage::class,
'feca-mailshots-test' => FecaMailshots\Admin\MailshotTestAdminPage::class,
'feca-mailshots-run' => FecaMailshots\Admin\RunMailshotAdminPage::class,
'feca-mailshots-review-recipients' => FecaMailshots\Admin\ReviewRecipientsAdminPage::class,
'feca-mailshots-download-pdf' => FecaMailshots\Admin\DownloadPdfAdminPage::class,
];
if (!isset($map[$page])) {

View File

@ -57,6 +57,13 @@ if (!function_exists('wp_verify_nonce')) {
}
}
if (!function_exists('wp_create_nonce')) {
function wp_create_nonce(string $action): string
{
return 'fixture-nonce-' . $action;
}
}
if (!function_exists('status_header')) {
function status_header(int $code): void
{

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Admin\DataSourcesAdminPage;
use FecaMailshots\Admin\SetupAdminPage;
use FecaMailshots\WordPress\FixtureWordPressFacade;
$wp = new FixtureWordPressFacade();
/** @param callable():void $fn */
$capture = static function (callable $fn): string {
ob_start();
try {
$fn();
return (string) ob_get_clean();
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
};
$assertTrue = static function (bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "Assertion failed: {$message}\n");
exit(1);
}
};
$assertContains = static function (string $needle, string $haystack, string $label): void {
if (strpos($haystack, $needle) === false) {
fwrite(STDERR, "Missing expected text in {$label}: {$needle}\n");
exit(1);
}
};
$setup = new SetupAdminPage($wp);
$dataSources = new DataSourcesAdminPage(
static function (): never {
throw new RuntimeException('Data source service should not be resolved in this test.');
},
$wp
);
// Editor policy: operational features allowed via edit_pages, setup denied.
$wp->setAllowedCapabilities(['edit_pages']);
$assertTrue($dataSources->restCanRead() === true, 'Editor must be able to read data source REST endpoints.');
$assertTrue($dataSources->restCanManage() === true, 'Editor must be able to manage data source REST endpoints.');
$setupDeniedHtml = $capture(static function () use ($setup): void {
$setup->render();
});
$assertContains('Permission denied', $setupDeniedHtml, 'Setup (editor)');
// Administrator policy: setup/settings allowed via manage_options.
$wp->setAllowedCapabilities(['manage_options']);
$setupAdminHtml = $capture(static function () use ($setup): void {
$setup->render();
});
$assertContains('FECA Mailshots Setup', $setupAdminHtml, 'Setup (admin)');
// No-capability baseline: operational data-source endpoints denied.
$wp->setAllowedCapabilities([]);
$assertTrue($dataSources->restCanRead() === false, 'User without edit_pages must not read data source REST endpoints.');
$assertTrue($dataSources->restCanManage() === false, 'User without edit_pages must not manage data source REST endpoints.');
echo "Access control policy integration test passed\n";

View File

@ -73,6 +73,7 @@ $assertContains('Create Attachment', $attachmentsHtml, 'Attachments');
$assertContains('feca_mailshots_attachments_ui_save', $attachmentsHtml, 'Attachments');
$assertContains('Existing Attachments', $attachmentsHtml, 'Attachments');
$assertNotContains('Use API endpoint', $attachmentsHtml, 'Attachments');
$assertNotContains('id="att_mime"', $attachmentsHtml, 'Attachments');
$pdfAssetsHtml = $capture(static function () use ($container): void {
$container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->render();
@ -81,5 +82,19 @@ $assertContains('Create PDF Asset', $pdfAssetsHtml, 'PDF Assets');
$assertContains('feca_mailshots_pdf_assets_ui_save', $pdfAssetsHtml, 'PDF Assets');
$assertContains('Existing PDF Assets', $pdfAssetsHtml, 'PDF Assets');
$assertNotContains('Use API endpoint', $pdfAssetsHtml, 'PDF Assets');
$assertNotContains('id="pdf_mime"', $pdfAssetsHtml, 'PDF Assets');
$reviewRecipientsHtml = $capture(static function () use ($container): void {
$container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->render();
});
$assertContains('Review Recipients', $reviewRecipientsHtml, 'Review Recipients');
$assertContains('Sort Direction', $reviewRecipientsHtml, 'Review Recipients');
$assertNotContains('>Apply<', $reviewRecipientsHtml, 'Review Recipients');
$downloadPdfHtml = $capture(static function () use ($container): void {
$container->get(FecaMailshots\Admin\DownloadPdfAdminPage::class)->render();
});
$assertContains('Download PDF', $downloadPdfHtml, 'Download PDF');
$assertContains('Download Merged PDF', $downloadPdfHtml, 'Download PDF');
echo "Phase 3 admin CRUD UI integration test passed\n";

View File

@ -108,7 +108,6 @@ try {
$savePdf = $pdfService->save(null, [
'name' => 'phase3_pdf_' . $unique,
'file_name' => 'logo.png',
'mime_type' => 'image/png',
'file_bytes_base64' => base64_encode('fakepngbytes'),
'width_mm' => '20',
'height_mm' => '10',

View File

@ -17,6 +17,7 @@ use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
@ -42,8 +43,11 @@ final class FakeCreds implements MailCredentialsProvider {
final class FakeSmtp implements SmtpSender {
public int $count = 0;
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null): array {
/** @var list<array{filename:string,mime_type:string,content_bytes:string}> */
public array $lastAttachments = [];
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array {
$this->count++;
$this->lastAttachments = $attachments;
if ($to === []) {
throw new RuntimeException('No recipients');
}
@ -76,6 +80,7 @@ $validator = new DslValidator($metadata);
$compiler = new DslCompiler($metadata);
$queryRepo = new MailshotQueryRepository($router);
$mailshotRepo = new MailshotRepository($router);
$attachmentRepo = new AttachmentRepository($router);
$dataSourceService = new DataSourceService($queryRepo, $router, $parser, $validator, $compiler, $metadata, $mailshotRepo);
$lastRunRepo = new LastRunRepository($router);
@ -84,6 +89,7 @@ $imap = new FakeImap();
$run = new MailshotRunService(
$mailshotRepo,
$queryRepo,
$attachmentRepo,
$dataSourceService,
new TemplateRenderer(),
$smtp,
@ -106,6 +112,8 @@ $uniq = gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3));
$queryName = 'phase4_ds_' . $uniq;
$queryId = null;
$mailshotId = null;
$attachmentId = null;
$attachmentName = 'phase4_att_' . $uniq;
try {
$savedQ = $dataSourceService->save(null, $queryName, $dsl);
@ -115,6 +123,13 @@ try {
}
$queryId = (int) $savedQ['id'];
$attachmentId = $attachmentRepo->create([
'name' => $attachmentName,
'file_name' => 'phase4-note.txt',
'mime_type' => 'text/plain',
'file_bytes' => 'phase4 attachment bytes',
]);
$savedM = $mailshotRepo->create([
'Purpose' => 'Phase4 ' . $uniq,
'DataSource' => $queryName,
@ -123,7 +138,7 @@ try {
'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}',
'Message' => '<p>Hi {{ name|default(Name|default("there")) }}</p>',
'PDFAttachment' => '<p>PDF {{ id|default("none") }}</p>',
'AttachmentNames' => '[]',
'AttachmentNames' => json_encode([$attachmentName], JSON_UNESCAPED_SLASHES),
'PDFFilenameDerivedFrom' => '',
'ReplyTo' => '',
]);
@ -135,6 +150,20 @@ try {
exit(1);
}
$pdfBatch = $run->generatePdfBatch($mailshotId);
if (($pdfBatch['ok'] ?? false) !== true) {
fwrite(STDERR, 'generatePdfBatch failed: ' . json_encode($pdfBatch) . "\n");
exit(1);
}
if ((int) ($pdfBatch['generated_count'] ?? 0) <= 0) {
fwrite(STDERR, "generatePdfBatch should generate at least one PDF\n");
exit(1);
}
if (strpos((string) ($pdfBatch['merged_pdf_bytes'] ?? ''), '%PDF') !== 0) {
fwrite(STDERR, "generatePdfBatch merged_pdf_bytes should be a PDF payload\n");
exit(1);
}
$sendBlank = $run->sendTest($mailshotId, 0, '');
if (($sendBlank['ok'] ?? true) !== false) {
fwrite(STDERR, "sendTest blank email should fail\n");
@ -146,6 +175,59 @@ try {
fwrite(STDERR, 'sendTest failed: ' . json_encode($sendTest) . "\n");
exit(1);
}
if (array_key_exists('rendered', $sendTest)) {
fwrite(STDERR, "sendTest should not return rendered payload\n");
exit(1);
}
if (count($smtp->lastAttachments) < 2) {
fwrite(STDERR, "sendTest should include static attachment and rendered PDF attachment\n");
exit(1);
}
$mimeTypes = array_map(static fn(array $a): string => (string) ($a['mime_type'] ?? ''), $smtp->lastAttachments);
if (!in_array('application/pdf', $mimeTypes, true)) {
fwrite(STDERR, "sendTest should include a rendered application/pdf attachment\n");
exit(1);
}
if (!in_array('text/plain', $mimeTypes, true)) {
fwrite(STDERR, "sendTest should include configured text/plain attachment\n");
exit(1);
}
$pdfAttachment = null;
foreach ($smtp->lastAttachments as $att) {
if ((string) ($att['mime_type'] ?? '') === 'application/pdf') {
$pdfAttachment = $att;
break;
}
}
if (!is_array($pdfAttachment) || strpos((string) ($pdfAttachment['content_bytes'] ?? ''), '%PDF') !== 0) {
fwrite(STDERR, "sendTest PDF attachment content is not a PDF payload\n");
exit(1);
}
// Regression: configured-but-missing attachment must return a controlled error, not throw/fatal.
$mailshotRepo->update($mailshotId, [
'Purpose' => 'Phase4 ' . $uniq,
'DataSource' => $queryName,
'CC' => '',
'BCC' => '',
'Subject' => 'Hello {{ id|default(ID|default("recipient")) }}',
'Message' => '<p>Hi {{ name|default(Name|default("there")) }}</p>',
'PDFAttachment' => '<p>PDF {{ id|default("none") }}</p>',
'AttachmentNames' => json_encode(['missing_attachment_name'], JSON_UNESCAPED_SLASHES),
'PDFFilenameDerivedFrom' => '',
'ReplyTo' => '',
'RecipientEmailField' => '',
]);
$sendMissing = $run->sendTest($mailshotId, 0, 'receiver@example.org');
if (($sendMissing['ok'] ?? true) !== false) {
fwrite(STDERR, "sendTest should fail when configured attachment is missing\n");
exit(1);
}
$missingText = implode('; ', (array) ($sendMissing['errors'] ?? []));
if (strpos($missingText, 'Attachment "missing_attachment_name" not found.') === false) {
fwrite(STDERR, "sendTest missing-attachment error text mismatch: {$missingText}\n");
exit(1);
}
$runRes = $run->runMailshot($mailshotId);
if (($runRes['ok'] ?? false) !== true) {
@ -178,6 +260,9 @@ try {
try { $lastRunRepo->clearForMailshot($mailshotId); } catch (Throwable $e) {}
try { $mailshotRepo->delete($mailshotId); } catch (Throwable $e) {}
}
if ($attachmentId !== null) {
try { $attachmentRepo->delete($attachmentId); } catch (Throwable $e) {}
}
if ($queryId !== null) {
try { $queryRepo->delete($queryId); } catch (Throwable $e) {}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\BasicSmtpSender;
$sender = new BasicSmtpSender();
$ref = new ReflectionClass($sender);
$buildMime = $ref->getMethod('buildMime');
$buildMime->setAccessible(true);
$plain = $buildMime->invoke(
$sender,
'from@example.org',
'From Name',
['to@example.org'],
[],
[],
'Subject',
'<p>Hello</p>',
null,
[]
);
if (!is_string($plain) || strpos($plain, 'Content-Type: text/html; charset=UTF-8') === false) {
fwrite(STDERR, "Plain MIME rendering missing HTML content-type header\n");
exit(1);
}
$bytes = random_bytes(256 * 1024);
$mime = $buildMime->invoke(
$sender,
'from@example.org',
'From Name',
['to@example.org'],
[],
[],
'Subject',
'<p>Hello</p>',
null,
[[
'filename' => 'sample.jpg',
'mime_type' => 'image/jpeg',
'content_bytes' => $bytes,
]]
);
if (!is_string($mime) || strpos($mime, 'multipart/mixed') === false) {
fwrite(STDERR, "Multipart MIME expected for attachment payload\n");
exit(1);
}
if (strpos($mime, 'Content-Type: image/jpeg; name="sample.jpg"') === false) {
fwrite(STDERR, "Attachment MIME part missing expected content-type/filename\n");
exit(1);
}
if (strpos($mime, base64_encode(substr($bytes, 0, 24))) === false) {
fwrite(STDERR, "Attachment bytes do not appear to be base64-encoded into MIME payload\n");
exit(1);
}
echo "BasicSmtpSender MIME regression test passed\n";

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
require_once __DIR__ . '/FakeMetadataProvider.php';
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
$metadata = new FakeMetadataProvider([
'accounts' => ['id', 'name', 'account_type_id', 'sector_id', 'public_location_id', 'type', 'public_location', 'account_sector'],
'contacts' => ['id', 'account_id', 'contact_email_1'],
]);
$parser = new DslParser();
$compiler = new DslCompiler($metadata);
$ast = $parser->parse("accounts and contacts where accounts.type = 'Member'");
$compiled = $compiler->compile($ast);
$sql = (string) ($compiled['sql'] ?? '');
if (strpos($sql, 'LEFT JOIN `picklist_account_type`') === false) {
fwrite(STDERR, "Expected implicit join to picklist_account_type\n");
exit(1);
}
if (strpos($sql, 'LEFT JOIN `picklist_public_location`') === false) {
fwrite(STDERR, "Expected implicit join to picklist_public_location\n");
exit(1);
}
if (strpos($sql, 'LEFT JOIN `picklist_sector`') === false) {
fwrite(STDERR, "Expected implicit join to picklist_sector\n");
exit(1);
}
if (strpos($sql, '`p_account_type`.`value` AS `accounts.type`') === false) {
fwrite(STDERR, "Expected accounts.type projection from picklist_account_type.value\n");
exit(1);
}
if (strpos($sql, '`p_public_location`.`value` AS `accounts.public_location`') === false) {
fwrite(STDERR, "Expected accounts.public_location projection from picklist_public_location.value\n");
exit(1);
}
if (strpos($sql, '`p_sector`.`value` AS `accounts.account_sector`') === false) {
fwrite(STDERR, "Expected accounts.account_sector projection from picklist_sector.value\n");
exit(1);
}
if (strpos($sql, '`p_account_type`.`value` = ?') === false) {
fwrite(STDERR, "Expected predicate accounts.type to compile against picklist value\n");
exit(1);
}
echo "DSL accounts picklist join regression test passed\n";

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\WordPress\ProductionWordPressFacade;
if (!function_exists('wp_unslash')) {
/**
* Minimal test shim for wp_unslash behavior used by the facade.
* @param mixed $value
* @return mixed
*/
function wp_unslash($value)
{
if (is_array($value)) {
return array_map('wp_unslash', $value);
}
return stripslashes((string) $value);
}
}
$facade = new ProductionWordPressFacade();
$input = '<p class=\"red\">For:&nbsp;<strong>{{ fenedgec_members_mailshot_test_name }}</strong></p>';
$_POST['Message'] = $input;
$actual = $facade->requestParam('Message', '');
$expected = '<p class="red">For:&nbsp;<strong>{{ fenedgec_members_mailshot_test_name }}</strong></p>';
if ($actual !== $expected) {
fwrite(STDERR, "ProductionWordPressFacade did not unslash request input correctly.\n");
fwrite(STDERR, "Expected: {$expected}\n");
fwrite(STDERR, "Actual: {$actual}\n");
exit(1);
}
echo "Production facade request unslash regression test passed\n";

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\TemplateRenderer;
$renderer = new TemplateRenderer(
static function (string $name): ?array {
if (strtolower(trim($name)) !== 'logo_asset') {
return null;
}
return [
'name' => 'logo_asset',
'file_name' => 'logo.png',
'mime_type' => 'image/png',
'file_bytes' => "PNG_BYTES",
'width_mm' => 20,
'height_mm' => 10,
'justification' => 'left',
];
}
);
$rendered = $renderer->render(
'Subject',
'<p>Body</p>',
'<div>{{ pdf_asset("logo_asset") }}</div>',
[]
);
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
if (strpos($pdfHtml, 'data:image/png;base64,') === false) {
fwrite(STDERR, "pdf_asset did not render data-uri image HTML\n");
exit(1);
}
if (strpos($pdfHtml, 'text-align:left') === false) {
fwrite(STDERR, "pdf_asset did not apply left justification wrapper\n");
exit(1);
}
if (strpos($pdfHtml, 'width:20mm;height:10mm;') === false) {
fwrite(STDERR, "pdf_asset did not apply width/height mm styles\n");
exit(1);
}
try {
$renderer->render('S', 'M', '{{ pdf_asset("missing_asset") }}', []);
fwrite(STDERR, "Expected hard error for missing pdf_asset, but render succeeded\n");
exit(1);
} catch (Throwable $e) {
if (strpos($e->getMessage(), 'not found') === false) {
fwrite(STDERR, "Unexpected missing-asset error message: {$e->getMessage()}\n");
exit(1);
}
}
echo "TemplateRenderer pdf_asset regression test passed\n";