Completing the PDF download and review recipients pages. Refactored.
This commit is contained in:
parent
1835e75924
commit
63e2daaa9b
|
|
@ -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.
|
||||
Binary file not shown.
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if ($op === 'delete') {
|
||||
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
|
||||
$this->service()->delete($id);
|
||||
$this->wp->sendJson(['ok' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
|
||||
$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.'];
|
||||
}
|
||||
$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.'];
|
||||
}
|
||||
$id = $this->requestInt('id', 0);
|
||||
$this->service()->delete($id);
|
||||
return ['ok' => true];
|
||||
}
|
||||
);
|
||||
} 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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
$result = $this->runService()->renderTest($mailshotId, $idx);
|
||||
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', '') ?? '');
|
||||
$result = $this->runService()->sendTest($mailshotId, $idx, $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 . '"}';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
var joditContainer = function () {
|
||||
if (!templateJodit) { return null; }
|
||||
var el = templateJodit.nextElementSibling;
|
||||
if (el && String(el.className || "").indexOf("jodit-container") >= 0) {
|
||||
return el;
|
||||
}
|
||||
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 () {
|
||||
openTemplateEditor(messageField, "Edit Message", false);
|
||||
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 () {
|
||||
openTemplateEditor(pdfField, "Edit PDF Attachment", true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if ($op === 'delete') {
|
||||
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
|
||||
$this->service()->delete($id);
|
||||
$this->wp->sendJson(['ok' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
|
||||
$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.'];
|
||||
}
|
||||
$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.'];
|
||||
}
|
||||
$id = $this->requestInt('id', 0);
|
||||
$this->service()->delete($id);
|
||||
return ['ok' => true];
|
||||
}
|
||||
);
|
||||
} 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'),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,20 +254,14 @@ 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) {
|
||||
$name = (string) ($row['schema_name'] ?? '');
|
||||
if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) {
|
||||
continue;
|
||||
}
|
||||
$schemas[] = $name;
|
||||
$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) {
|
||||
$name = (string) ($row['schema_name'] ?? '');
|
||||
if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) {
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// no-op: fallback is the configured members schema above
|
||||
$schemas[] = $name;
|
||||
}
|
||||
|
||||
$schemas = array_values(array_unique($schemas));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
|
||||
if (!isset($rows[$recipientIndex])) {
|
||||
return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
|
||||
}
|
||||
|
||||
try {
|
||||
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
|
||||
if (!isset($rows[$recipientIndex])) {
|
||||
return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
|
||||
}
|
||||
|
||||
$rendered = $this->renderer->render(
|
||||
(string) $mailshot['Subject'],
|
||||
(string) $mailshot['Message'],
|
||||
|
|
@ -103,28 +138,31 @@ final class MailshotRunService
|
|||
return ['ok' => false, 'errors' => ['Test email address is required.']];
|
||||
}
|
||||
|
||||
$creds = $this->credentials->credentials();
|
||||
if ($creds === null) {
|
||||
return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
|
||||
}
|
||||
|
||||
$render = $this->renderTest($mailshotId, $recipientIndex);
|
||||
if (($render['ok'] ?? false) !== true) {
|
||||
return $render;
|
||||
}
|
||||
|
||||
$mailshot = $this->mailshots->find($mailshotId);
|
||||
if ($mailshot === null) {
|
||||
return ['ok' => false, 'errors' => ['Mailshot not found.']];
|
||||
}
|
||||
|
||||
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
|
||||
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
|
||||
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
|
||||
|
||||
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
|
||||
|
||||
try {
|
||||
$creds = $this->credentials->credentials();
|
||||
if ($creds === null) {
|
||||
return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
|
||||
}
|
||||
|
||||
$render = $this->renderTest($mailshotId, $recipientIndex);
|
||||
if (($render['ok'] ?? false) !== true) {
|
||||
return $render;
|
||||
}
|
||||
|
||||
$mailshot = $this->mailshots->find($mailshotId);
|
||||
if ($mailshot === null) {
|
||||
return ['ok' => false, 'errors' => ['Mailshot not found.']];
|
||||
}
|
||||
|
||||
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
|
||||
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
|
||||
$attachments = array_merge(
|
||||
$this->staticAttachments($mailshot),
|
||||
$this->renderedPdfAttachments($mailshot, (array) ($render['recipient'] ?? []), (array) ($render['rendered'] ?? []))
|
||||
);
|
||||
|
||||
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
|
||||
|
||||
$send = $this->smtp->send(
|
||||
$creds,
|
||||
[$testEmail],
|
||||
|
|
@ -132,26 +170,25 @@ 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);
|
||||
} catch (\Throwable $e) {
|
||||
$warnings[] = 'IMAP sent-copy failed: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
try {
|
||||
$this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId);
|
||||
return [
|
||||
'ok' => true,
|
||||
'warnings' => $warnings,
|
||||
'sent_to' => $testEmail,
|
||||
'sent_at' => gmdate('c'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$warnings[] = 'IMAP sent-copy failed: ' . $e->getMessage();
|
||||
return ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'warnings' => $warnings,
|
||||
'sent_to' => $testEmail,
|
||||
'sent_at' => gmdate('c'),
|
||||
'rendered' => $render['rendered'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,9 +83,68 @@ final class BasicSmtpSender implements SmtpSender
|
|||
}
|
||||
$headers[] = 'Subject: ' . $subject;
|
||||
$headers[] = 'MIME-Version: 1.0';
|
||||
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||||
if ($attachments === []) {
|
||||
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||||
return implode("\r\n", $headers) . "\r\n\r\n" . $htmlBody;
|
||||
}
|
||||
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +43,10 @@ final class FixtureWordPressFacade implements WordPressFacade
|
|||
|
||||
public function currentUserCan(string $capability): bool
|
||||
{
|
||||
return true;
|
||||
if ($this->allowedCapabilities === null) {
|
||||
return true;
|
||||
}
|
||||
return isset($this->allowedCapabilities[$capability]);
|
||||
}
|
||||
|
||||
public function verifyNonce(string $nonce, string $action): bool
|
||||
|
|
@ -40,6 +54,11 @@ final class FixtureWordPressFacade implements WordPressFacade
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 2026–2027</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 group’s 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 group’s 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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`;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
||||
FIXTURE_PORT="${FIXTURE_PORT}" scripts/run_fixture_server.sh start >/dev/null
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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])) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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: <strong>{{ fenedgec_members_mailshot_test_name }}</strong></p>';
|
||||
$_POST['Message'] = $input;
|
||||
|
||||
$actual = $facade->requestParam('Message', '');
|
||||
$expected = '<p class="red">For: <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";
|
||||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue