Compare commits

..

10 Commits

60 changed files with 2114 additions and 645 deletions

View File

@ -69,6 +69,15 @@ Examples:
- `credentials/.env` and key material are ignored by `.gitignore`.
- Per-user mail credentials are stored encrypted in the mailshots DB.
## Mail Sending Notes
- Mailshots send through WordPress's `wp_mail()`/PHPMailer service, configured with the current user's saved SMTP credentials for each send.
- SMTP EHLO uses the domain from the configured From email address.
- Standard message headers and MIME formatting are generated by WordPress/PHPMailer.
- BCC recipients are delivered through PHPMailer recipient handling and are not rendered into the message headers.
- Generated send-time attachments, including rendered PDFs, are added to PHPMailer as string attachments. Before SMTP send, the run service estimates MIME size and fails with a controlled diagnostic when the message would exceed `FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES` (default 25 MB).
- PDF download workflows stream ZIP and merged PDF output from temporary files instead of returning large generated PDF byte arrays through the application/API layer.
## Important References
- Environment baseline: `requirements/environment.md`

BIN
dist/feca_mailshots_plugin-1.1.0.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.11.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.17.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.18.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.19.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.20.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.21.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.22.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.6.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.8.zip vendored Normal file

Binary file not shown.

5
docs/todo.md Normal file
View File

@ -0,0 +1,5 @@
Split advertiser name into forename and surname - done in twig in message
Add ad sizes into mailmerge - done
Why does filter ads and advertisers where issue(125) and ads.Price != 0 and not (advertisers.Categories contains 'discount') identify fewer records than expected. - donei

View File

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

View File

@ -39,6 +39,7 @@ trait AdminRequestHelpers
. '.feca-mailshots-admin .feca-inline-control-group .feca-control{padding:0;}'
. '.feca-mailshots-admin .feca-button-row{display:flex;gap:var(--feca-space-2);align-items:center;flex-wrap:wrap;margin-top:var(--feca-space-3);margin-bottom:0;padding:0;}'
. '.feca-mailshots-admin .feca-button-row .button{margin:0 !important;}'
. '.feca-mailshots-admin .feca-two-line-button{height:auto;min-height:44px;line-height:1.25;white-space:normal;max-width:260px;text-align:center;padding-top:6px;padding-bottom:6px;}'
. '.feca-mailshots-admin .feca-button-danger{color:#b32d2e;border-color:#d63638;}'
. '.feca-mailshots-admin .feca-button-danger:hover{color:#8a2424;border-color:#8a2424;background:#fff5f5;}'
. '.feca-mailshots-admin .feca-banner{padding:var(--feca-space-2) var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin:var(--feca-space-3) 0;border-radius:var(--feca-radius);}'
@ -54,6 +55,9 @@ trait AdminRequestHelpers
. '.feca-mailshots-admin .feca-form .form-table th,.feca-mailshots-admin .feca-form .form-table td{padding-top:12px;padding-bottom:12px;}'
. '.feca-mailshots-admin .feca-form .form-table th{width:220px;}'
. '.feca-mailshots-admin .feca-form .description{margin-top:var(--feca-space-1);}'
. '.feca-mailshots-admin .feca-password-control{display:flex;align-items:center;gap:8px;max-width:34rem;}'
. '.feca-mailshots-admin .feca-password-control .regular-text{flex:1;min-width:220px;margin:0;}'
. '.feca-mailshots-admin .feca-password-toggle{min-width:74px;text-align:center;}'
. '.feca-mailshots-admin .feca-inline-form{display:inline;}'
. '.feca-mailshots-admin .feca-hidden{display:none;}'
. '.feca-mailshots-admin .feca-meta-line{margin:4px 0 var(--feca-space-3) 0;color:#6b7280;font-size:12px;}'

View File

@ -88,10 +88,11 @@ final class DataSourcesAdminPage
$editId = $draftId;
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['ID'] ?? 0) === $editId) {
$editItem = $row;
break;
if ($editId > 0) {
try {
$editItem = $this->service()->get($editId);
} catch (\Throwable $e) {
$editItem = null;
}
}
$name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : '';
@ -101,8 +102,7 @@ final class DataSourcesAdminPage
$dsl = (string) ($draft['dsl_text'] ?? $dsl);
}
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$sourceFieldsMap = $this->service()->sourceFields();
$schemaList = $this->service()->listSchemas();
$builtInSources = $this->service()->knownSources();
echo '<div class="wrap feca-mailshots-admin">';
echo $this->renderAdminUiStyles();
@ -184,21 +184,13 @@ final class DataSourcesAdminPage
echo '<div class="feca-builder-grid">';
echo '<div class="feca-builder-col-left">';
echo '<h3 class="feca-modal-title">Data Sources</h3>';
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="advertisers"> advertisers</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="ads"> ads</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="pages"> pages</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="articles"> articles</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="issues"> issues</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="invoices"> invoices</label><br><br>';
foreach ($builtInSources as $builtInSource) {
$escapedSource = htmlspecialchars((string) $builtInSource, ENT_QUOTES);
echo '<label><input type="checkbox" class="ds-source-built" value="' . $escapedSource . '"> ' . $escapedSource . '</label><br>';
}
echo '<br>';
echo '<strong>Add custom source</strong><br>';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
foreach ($schemaList as $schema) {
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> ';
echo '<button type="button" class="button button-small" id="ds-builder-add-custom">Add</button>';
@ -290,7 +282,7 @@ final class DataSourcesAdminPage
echo '</div></div>';
echo '<script>';
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
'sourceFields' => $sourceFieldsMap,
'sourceFields' => [],
'restBase' => '/wp-json/mailshots/v1/data-sources',
'adminPostApi' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_data_sources_api'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
@ -325,16 +317,69 @@ final class DataSourcesAdminPage
echo 'var customSources=[];';
echo 'var sourceOrder=[];';
echo 'var constraints=[];';
echo 'var sourceFieldRequests={};';
echo 'var schemasLoaded=false;';
echo 'var schemasLoading=false;';
echo 'var isDirty=false;';
echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}';
echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}';
echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["account-has-article-in-issue","Account has article in issue","issue"],["selected","Advertiser is selected","none"],["issue","Issue is","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-account","Account is member","none"],["affiliate-account","Account is affiliate","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["account-has-article-in-issue","Account has article in issue","issue"],["selected","Advertiser is selected","none"],["issue","Issue is","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
echo 'function selectedSources(){var selected={};builtChecks.forEach(function(c){if(c.checked){selected[c.value]=true;}});customSources.forEach(function(v){selected[v]=true;});var s=[];sourceOrder.forEach(function(src){if(selected[src]&&s.indexOf(src)===-1){s.push(src);}});builtChecks.forEach(function(c){if(c.checked&&s.indexOf(c.value)===-1){s.push(c.value);}});customSources.forEach(function(v){if(s.indexOf(v)===-1){s.push(v);}});return s;}';
echo 'function noteSourceSelected(src){if(src&&sourceOrder.indexOf(src)===-1){sourceOrder.push(src);}}';
echo 'function noteSourceDeselected(src){sourceOrder=sourceOrder.filter(function(v){return v!==src;});}';
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(){noteSourceDeselected(src);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||"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 [];}';
$lazyMetadataJs = <<<'JS'
function loadSchemas(){
if(schemasLoaded||schemasLoading||!schemaSel){return;}
schemasLoading=true;
var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=schemas";
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");}
schemaSel.innerHTML="<option value=\"\">Select schema</option>";
(j.items||[]).forEach(function(schema){
var value=String(schema||"").trim();
if(!value){return;}
var option=document.createElement("option");
option.value=value;
option.textContent=value;
schemaSel.appendChild(option);
});
schemasLoaded=true;
schemasLoading=false;
}).catch(function(e){
schemasLoading=false;
if(err){err.textContent="Schema lookup failed: "+(e&&e.message?e.message:"unknown error");}
});
}
function sourceFields(source){
var map=cfg.sourceFields||{};
if(Object.prototype.hasOwnProperty.call(map,source)){return map[source];}
if(!source||sourceFieldRequests[source]){return [];}
sourceFieldRequests[source]=true;
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){
delete sourceFieldRequests[source];
if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}
var fields=(j&&j.fields)||[];
map[source]=fields;
cfg.sourceFields=map;
if(fields.length){renderRows();updateDsl();return;}
if(err){err.textContent="Source field lookup returned no fields for "+source+".";}
}).catch(function(e){
delete sourceFieldRequests[source];
if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}
});
return [];
}
JS;
echo $lazyMetadataJs;
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 filterArgType(name){for(var i=0;i<filters.length;i++){if(filters[i][0]===name){return filters[i][2]||"none";}}return "none";}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
@ -355,7 +400,7 @@ final class DataSourcesAdminPage
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}noteSourceSelected(src);updateCustomList();renderRows();updateDsl();};}';
echo 'builtChecks.forEach(function(c){c.onchange=function(){if(c.checked){noteSourceSelected(c.value);}else{noteSourceDeselected(c.value);}renderRows();updateDsl();};});';
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(openBtn){openBtn.onclick=function(){loadSchemas();resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}';
echo 'if(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}';

View File

@ -112,6 +112,13 @@ final class DownloadPdfAdminPage
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 'var mailshotSelect=document.getElementById("dp_mailshot_id");';
echo 'var selectedKey="fecaMailshotsSelectedMailshotId";';
echo 'function hasOption(value){if(!mailshotSelect){return false;}for(var i=0;i<mailshotSelect.options.length;i++){if(String(mailshotSelect.options[i].value||"")===String(value||"")){return true;}}return false;}';
echo 'function currentUrl(){return new URL(window.location.href);}';
echo 'function updateUrl(value){try{var url=currentUrl();url.searchParams.set("page","' . self::PAGE_SLUG . '");if(value){url.searchParams.set("mailshot_id",String(value));}else{url.searchParams.delete("mailshot_id");}window.history.replaceState({},"",url.toString());}catch(_){}}';
echo 'try{var url=currentUrl();var hasExplicit=url.searchParams.has("mailshot_id");var stored=String(sessionStorage.getItem(selectedKey)||"").trim();if(!hasExplicit&&stored&&hasOption(stored)&&mailshotSelect&&String(mailshotSelect.value||"")!==stored){url.searchParams.set("page","' . self::PAGE_SLUG . '");url.searchParams.set("mailshot_id",stored);window.location.replace(url.toString());return;}}catch(_){}';
echo 'if(mailshotSelect){try{if(mailshotSelect.value){sessionStorage.setItem(selectedKey,String(mailshotSelect.value));}}catch(_){}updateUrl(String(mailshotSelect.value||""));mailshotSelect.addEventListener("change",function(){var value=String(mailshotSelect.value||"");try{if(value){sessionStorage.setItem(selectedKey,value);}else{sessionStorage.removeItem(selectedKey);}}catch(_){}updateUrl(value);});}';
echo 'var clearUi=function(){if(progress){progress.style.display="none";}if(merged){merged.disabled=false;merged.classList.remove("disabled");}if(zip){zip.disabled=false;zip.classList.remove("disabled");}};';
echo 'actualForm.addEventListener("submit",function(ev){';
echo 'var submitter=ev&&ev.submitter?ev.submitter:null;';
@ -188,34 +195,22 @@ final class DownloadPdfAdminPage
return;
}
$bytes = (string) ($result['merged_pdf_bytes'] ?? '');
if ($bytes === '') {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF bytes are empty.']]);
$mergedPath = (string) ($result['merged_pdf_path'] ?? '');
if ($mergedPath === '' || !is_file($mergedPath)) {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: output file not found.']]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return;
}
try {
$this->sendBinaryDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $bytes);
$this->sendFileDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $mergedPath);
} catch (\Throwable $e) {
@unlink($mergedPath);
$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()) {

View File

@ -41,6 +41,7 @@ final class MailshotTestAdminPage
$this->wp->addAction('admin_post_feca_mailshots_test_api', [$this, 'handleApi']);
$this->wp->addAction('admin_post_feca_mailshots_test_render_ui', [$this, 'handleRenderUi']);
$this->wp->addAction('admin_post_feca_mailshots_test_send_ui', [$this, 'handleSendUi']);
$this->wp->addAction('admin_post_feca_mailshots_test_send_all_ui', [$this, 'handleSendAllUi']);
}
public function registerMenu(): void
@ -172,7 +173,16 @@ final class MailshotTestAdminPage
echo '</select></div>';
echo '</div>';
echo '</form>';
echo '<script>(function(){var select=document.getElementById("mst_mailshot_id");var form=document.getElementById("mst-mailshot-select-form");if(select&&form){select.addEventListener("change",function(){form.submit();});}})();</script>';
echo '<script>(function(){';
echo 'var select=document.getElementById("mst_mailshot_id");';
echo 'var form=document.getElementById("mst-mailshot-select-form");';
echo 'var key="fecaMailshotsSelectedMailshotId";';
echo 'function hasOption(value){if(!select){return false;}for(var i=0;i<select.options.length;i++){if(String(select.options[i].value||"")===String(value||"")){return true;}}return false;}';
echo 'function currentUrl(){return new URL(window.location.href);}';
echo 'try{var url=currentUrl();var hasExplicit=url.searchParams.has("mailshot_id");var stored=String(sessionStorage.getItem(key)||"").trim();if(!hasExplicit&&stored&&hasOption(stored)&&select&&String(select.value||"")!==stored){url.searchParams.set("page","feca-mailshots-test");url.searchParams.set("mailshot_id",stored);window.location.replace(url.toString());return;}}catch(_){}';
echo 'if(select){try{if(select.value){sessionStorage.setItem(key,String(select.value));}}catch(_){}}';
echo 'if(select&&form){select.addEventListener("change",function(){try{sessionStorage.setItem(key,String(select.value||""));}catch(_){}form.submit();});}';
echo '})();</script>';
echo '</div>';
if (!empty($preview['errors'])) {
@ -208,7 +218,8 @@ final class MailshotTestAdminPage
echo '<label for="mst_test_email"><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 class="feca-control"><label>&nbsp;</label><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" id="mst-send-test-button" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend() : confirm(\'Send one test email to the entered address?\');">Send Test Email</button></div>';
echo '<div class="feca-control"><label>&nbsp;</label><button class="button button-primary feca-two-line-button" type="submit" name="action" value="feca_mailshots_test_send_ui" id="mst-send-test-button" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend(\'selected\') : confirm(\'Send one test email to the entered address?\');">Send Test for selected row to Test Email Address</button></div>';
echo '<div class="feca-control"><label>&nbsp;</label><button class="button feca-two-line-button" type="submit" name="action" value="feca_mailshots_test_send_all_ui" id="mst-send-test-all-button" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend(\'all\') : confirm(\'Send test emails to the entered address for every recipient row?\');">Send Test for all to Test Email Address</button></div>';
echo '</div>';
echo '<div class="feca-control"><label>&nbsp;</label><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></div>';
echo '</div>';
@ -218,17 +229,20 @@ final class MailshotTestAdminPage
echo 'var form=document.getElementById("mst-action-form");';
echo 'var inlineResult=document.getElementById("mst-inline-result");';
echo 'var sendButton=document.getElementById("mst-send-test-button");';
echo 'var sendAllButton=document.getElementById("mst-send-test-all-button");';
echo 'var sendActions=["feca_mailshots_test_send_ui","feca_mailshots_test_send_all_ui"];';
echo 'function recipientCount(select){var count=parseInt(select&&select.getAttribute("data-recipient-count")?select.getAttribute("data-recipient-count"):"0",10);return Number.isFinite(count)&&count>=0?count:0;}';
echo 'function showResult(ok,title,lines){if(!inlineResult){return;}var wrap=document.createElement("div");wrap.className="feca-banner "+(ok?"feca-banner-success":"feca-banner-error");var strong=document.createElement("strong");strong.textContent=title;wrap.appendChild(strong);(lines||[]).forEach(function(line){var p=document.createElement("p");p.className="feca-banner-note";p.textContent=String(line||"");wrap.appendChild(p);});inlineResult.innerHTML="";inlineResult.appendChild(wrap);wrap.scrollIntoView({block:"nearest"});}';
echo 'if(form){form.addEventListener("submit",function(ev){var submitter=ev.submitter;if(!submitter||String(submitter.value||"")!=="feca_mailshots_test_send_ui"){return;}ev.preventDefault();var payload=new URLSearchParams(new FormData(form));payload.delete("action");if(sendButton){sendButton.disabled=true;}showResult(true,"Sending test email...",["Rendering PDF attachment and sending message."]);fetch(apiUrl,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(response){return response.text().then(function(text){var data=null;try{data=JSON.parse(text);}catch(e){var raw=String(text||"");var snippet="";if(raw){var doc=(new DOMParser()).parseFromString(raw,"text/html");snippet=String((doc&&doc.body&&doc.body.textContent)?doc.body.textContent:raw).split("\n").join(" ").split("\r").join(" ").split("\t").join(" ").trim().slice(0,300);}var msg="Send test failed: server returned HTTP "+response.status+" instead of JSON.";if(snippet){msg+=" Response began: "+snippet;}else{msg+=" Check the WordPress/PHP error log for the underlying fatal error.";}return {ok:false,errors:[msg]};}if(!response.ok&&data&&data.ok!==false){data.ok=false;}return data;});}).then(function(data){if(data&&data.ok){var lines=[];if(data.sent_to){lines.push("Sent to: "+data.sent_to);}if(data.sent_at){lines.push("Sent at: "+data.sent_at);}if(data.warnings&&data.warnings.length){lines=lines.concat(data.warnings.map(function(w){return "Warning: "+w;}));}showResult(true,"Test action succeeded.",lines.length?lines:["Sent."]);return;}var errors=(data&&data.errors&&data.errors.length)?data.errors:[(data&&data.error)?data.error:"Unknown send-test failure."];showResult(false,"Test action failed.",errors);}).catch(function(error){showResult(false,"Test action failed.",[error&&error.message?error.message:"Request failed."]);}).finally(function(){if(sendButton){sendButton.disabled=false;}});});}';
echo 'if(form){form.addEventListener("submit",function(ev){var submitter=ev.submitter;var action=submitter?String(submitter.value||""):"";if(sendActions.indexOf(action)===-1){return;}ev.preventDefault();var select=document.getElementById("mst_recipient_index");var sendAll=action==="feca_mailshots_test_send_all_ui";if(!sendAll&&select&&String(select.value||"0")==="-1"){showResult(false,"Test action failed.",["Choose a specific recipient row, or use Send Test for all to Test Email Address."]);return;}var payload=new URLSearchParams(new FormData(form));payload.delete("action");if(sendAll){payload.set("recipient_index","-1");payload.set("send_scope","all");}else{payload.set("send_scope","selected");}if(sendButton){sendButton.disabled=true;}if(sendAllButton){sendAllButton.disabled=true;}showResult(true,"Sending test email...",[sendAll?"Rendering PDF attachments and sending messages.":"Rendering PDF attachment and sending message."]);fetch(apiUrl,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(response){return response.text().then(function(text){var data=null;try{data=JSON.parse(text);}catch(e){var raw=String(text||"");var snippet="";if(raw){var doc=(new DOMParser()).parseFromString(raw,"text/html");snippet=String((doc&&doc.body&&doc.body.textContent)?doc.body.textContent:raw).split("\n").join(" ").split("\r").join(" ").split("\t").join(" ").trim().slice(0,300);}var msg="Send test failed: server returned HTTP "+response.status+" instead of JSON.";if(snippet){msg+=" Response began: "+snippet;}else{msg+=" Check the WordPress/PHP error log for the underlying fatal error.";}return {ok:false,errors:[msg]};}if(!response.ok&&data&&data.ok!==false){data.ok=false;}return data;});}).then(function(data){if(data&&data.ok){var lines=[];if(data.sent_to){lines.push("Sent to: "+data.sent_to);}if(data.sent_at){lines.push("Sent at: "+data.sent_at);}if(data.warnings&&data.warnings.length){lines=lines.concat(data.warnings.map(function(w){return "Warning: "+w;}));}showResult(true,"Test action succeeded.",lines.length?lines:["Sent."]);return;}var errors=(data&&data.errors&&data.errors.length)?data.errors:[(data&&data.error)?data.error:"Unknown send-test failure."];showResult(false,"Test action failed.",errors);}).catch(function(error){showResult(false,"Test action failed.",[error&&error.message?error.message:"Request failed."]);}).finally(function(){if(sendButton){sendButton.disabled=false;}if(sendAllButton){sendAllButton.disabled=false;}});});}';
echo 'window.fecaConfirmTestSend=function(){';
echo 'var scope=arguments.length>0?String(arguments[0]||"selected"):"selected";';
echo 'var select=document.getElementById("mst_recipient_index");';
echo 'if(!select){return confirm("Send one test email to the entered address?");}';
echo 'var value=String(select.value||"0");';
echo 'if(value==="-1"){';
echo 'var count=parseInt(select.getAttribute("data-recipient-count")||"0",10);';
echo 'if(!Number.isFinite(count)||count<0){count=0;}';
echo 'if(scope==="all"){';
echo 'var count=recipientCount(select);';
echo 'return confirm("Send "+count+" emails to the entered address?");';
echo '}';
echo 'if(String(select.value||"0")==="-1"){return true;}';
echo 'return confirm("Send one test email to the entered address?");';
echo '};';
echo '})();</script>';
@ -358,11 +372,16 @@ final class MailshotTestAdminPage
$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', '') ?? '');
if ($idx < 0) {
$scope = (string) ($this->wp->requestParam('send_scope', 'selected') ?? 'selected');
if ($scope === 'all') {
$stage = 'sending test email for all recipient rows';
$this->wp->sendJson($service->sendTestAll($mailshotId, $to));
return;
}
if ($idx < 0) {
$this->wp->sendJson(['ok' => false, 'errors' => ['Choose a specific recipient row, or use Send Test for all to Test Email Address.']]);
return;
}
$stage = 'sending test email for selected recipient';
$this->wp->sendJson($service->sendTest($mailshotId, $idx, $to));
return;
@ -410,8 +429,7 @@ final class MailshotTestAdminPage
if ($memoryError !== null) {
$result = ['ok' => false, 'errors' => [$memoryError]];
} elseif ($idx < 0) {
$stage = 'sending test email for all recipient rows';
$result = $this->runService()->sendTestAll($mailshotId, $email);
$result = ['ok' => false, 'errors' => ['Choose a specific recipient row, or use Send Test for all to Test Email Address.']];
} else {
$stage = 'sending test email for selected recipient';
$result = $this->runService()->sendTest($mailshotId, $idx, $email);
@ -425,6 +443,33 @@ final class MailshotTestAdminPage
$this->redirect($mailshotId, $idx, $email);
}
public function handleSendAllUi(): void
{
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', '') ?? '');
$stage = 'initializing send test for all recipient rows';
try {
$stage = 'raising memory limit for send test';
$memoryError = $this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
if ($memoryError !== null) {
$result = ['ok' => false, 'errors' => [$memoryError]];
} else {
$stage = 'sending test email for all recipient rows';
$result = $this->runService()->sendTestAll($mailshotId, $email);
}
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => [$this->diagnosticError('Send test failed', $stage, $e)]];
}
unset($result['rendered']);
$result['ui_action'] = 'send';
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId, $idx, $email);
}
private function redirect(int $mailshotId, int $recipientIndex, string $testEmail, bool $renderTest = false): void
{
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-test&mailshot_id=' . $mailshotId . '&recipient_index=' . $recipientIndex . '&test_email=' . rawurlencode($testEmail));

View File

@ -74,10 +74,11 @@ final class MailshotsAdminPage
$editId = $draftId;
}
$editItem = null;
foreach ($items as $row) {
if ((int) ($row['id'] ?? 0) === $editId) {
$editItem = $row;
break;
if ($editId > 0) {
try {
$editItem = $this->service()->find($editId);
} catch (\Throwable $e) {
$editItem = null;
}
}

View File

@ -46,16 +46,39 @@ final class ProfileAdminPage
}
$uid = $this->wp->currentUserId();
$saved = $uid > 0 ? ($this->repo()->findByUserId($uid) ?? []) : [];
$saved = [];
$loadError = '';
if ($uid > 0) {
try {
$saved = $this->repo()->findByUserId($uid) ?? [];
} catch (\Throwable $e) {
$loadError = 'Unable to load stored profile credentials: ' . $e->getMessage();
}
}
$status = $this->wp->requestParam('saved', '') === '1' ? 'Profile credentials saved.' : '';
$test = $uid > 0 ? $this->testResult($uid) : null;
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$missingPasswords = $loadError === '' ? $this->missingPasswordLabels($saved) : [];
echo '<div class="wrap feca-mailshots-admin"><h1>Mailshot Profile</h1>';
echo $this->renderAdminUiStyles();
if ($loadError !== '') {
echo '<div class="feca-banner feca-banner-error">' . htmlspecialchars($loadError) . '</div>';
}
if ($status !== '') {
echo '<div class="feca-banner feca-banner-success">' . htmlspecialchars($status) . '</div>';
}
if ($missingPasswords !== []) {
echo '<div class="feca-banner feca-banner-error">';
echo '<strong>' . htmlspecialchars('Missing password configuration.') . '</strong>';
echo '<p>' . htmlspecialchars('Enter and save the missing password before running mailshot tests or sends.') . '</p>';
echo '<ul>';
foreach ($missingPasswords as $label) {
echo '<li>' . htmlspecialchars($label) . '</li>';
}
echo '</ul>';
echo '</div>';
}
if ($test !== null) {
$ok = !empty($test['ok']);
$kind = strtoupper((string) ($test['kind'] ?? 'CREDENTIAL'));
@ -92,7 +115,7 @@ final class ProfileAdminPage
$this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? '');
$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', 'Required on each save/test request');
$this->field('SMTP Password', 'smtp_password', (string) ($saved['smtp_password'] ?? ''), 'password');
$this->field('From Email', 'smtp_from_email', $saved['smtp_from_email'] ?? '');
$this->field('From Name', 'smtp_from_name', $saved['smtp_from_name'] ?? '');
@ -105,7 +128,7 @@ final class ProfileAdminPage
$this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? '');
$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', 'Required on each save/test request');
$this->field('IMAP Password', 'imap_password', (string) ($saved['imap_password'] ?? ''), 'password');
$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 '</table>';
@ -116,7 +139,9 @@ final class ProfileAdminPage
echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test IMAP login" onclick="this.form.test_kind.value=\'imap\';">Test IMAP</button>';
echo '<input type="hidden" name="test_kind" value="">';
echo '</p>';
echo '</form></div>';
echo '</form>';
echo $this->renderPasswordToggleScript();
echo '</div>';
}
public function handleSave(): void
@ -210,6 +235,8 @@ final class ProfileAdminPage
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')),
];
$payload = $this->applyStoredPasswordsForTest($uid, $payload);
$result = $kind === 'smtp' ? $this->runSmtpTest($payload) : $this->runImapTest($payload);
$this->wp->updateOption($this->testResultOptionKey($uid), $result);
@ -225,13 +252,76 @@ final class ProfileAdminPage
{
echo '<tr>';
echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>';
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
echo '<td>';
if ($type === 'password') {
echo '<span class="feca-password-control">';
echo '<input class="regular-text" type="password" autocomplete="off" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
echo '<button type="button" class="button feca-password-toggle" data-feca-password-toggle="' . htmlspecialchars($name) . '" aria-controls="' . htmlspecialchars($name) . '" aria-pressed="false">' . htmlspecialchars('Show') . '</button>';
echo '</span>';
} else {
echo '<input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
}
if ($hint !== '') {
echo '<p class="description">' . htmlspecialchars($hint) . '</p>';
}
echo '</td></tr>';
}
private function renderPasswordToggleScript(): string
{
return <<<'HTML'
<script>
(function () {
var buttons = document.querySelectorAll('[data-feca-password-toggle]');
Array.prototype.forEach.call(buttons, function (button) {
button.addEventListener('click', function () {
var fieldId = button.getAttribute('data-feca-password-toggle');
var field = fieldId ? document.getElementById(fieldId) : null;
if (!field) {
return;
}
var visible = field.type === 'text';
field.type = visible ? 'password' : 'text';
button.setAttribute('aria-pressed', visible ? 'false' : 'true');
button.textContent = visible ? 'Show' : 'Hide';
});
});
}());
</script>
HTML;
}
/** @param array<string, mixed> $saved @return list<string> */
private function missingPasswordLabels(array $saved): array
{
$missing = [];
if (trim((string) ($saved['smtp_password'] ?? '')) === '') {
$missing[] = 'SMTP password is missing.';
}
if (trim((string) ($saved['imap_password'] ?? '')) === '') {
$missing[] = 'IMAP password is missing.';
}
return $missing;
}
/** @param array<string, mixed> $payload @return array<string, mixed> */
private function applyStoredPasswordsForTest(int $uid, array $payload): array
{
try {
$saved = $this->repo()->findByUserId($uid) ?? [];
} catch (\Throwable $e) {
return $payload;
}
foreach (['smtp_password', 'imap_password'] as $key) {
if (trim((string) ($payload[$key] ?? '')) === '' && trim((string) ($saved[$key] ?? '')) !== '') {
$payload[$key] = (string) $saved[$key];
}
}
return $payload;
}
private function repo(): MailCredentialRepository
{
return ($this->repoFactory)();

View File

@ -13,6 +13,8 @@ final class ReviewRecipientsAdminPage
private const CAPABILITY = 'edit_pages';
private const PAGE_SLUG = 'feca-mailshots-review-recipients';
private const DEFAULT_PAGE_SIZE = 200;
private const MAX_PAGE_SIZE = 500;
/** @var callable(): DataSourceService */
private $serviceFactory;
@ -43,26 +45,13 @@ final class ReviewRecipientsAdminPage
return;
}
$service = $this->service();
$sources = $service->list();
$sources = $this->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));
}
$sourceNames = [];
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name !== '') {
$sourceNames[] = $name;
}
}
@ -76,18 +65,14 @@ final class ReviewRecipientsAdminPage
echo '<label for="rr_data_source"><strong>Data Source</strong></label>';
echo '<select id="rr_data_source" class="feca-minw-220">';
echo '<option value="">Select data source</option>';
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === '') {
continue;
}
foreach ($sourceNames as $name) {
$selected = $name === $selectedSource ? ' selected' : '';
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>';
}
echo '</select></div>';
echo '<div class="feca-control feca-control-min-360">';
echo '<label for="rr_filter"><strong>Filter by</strong></label>';
echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches any field">';
echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches loaded fields">';
echo '</div>';
echo '<div class="feca-control feca-control-min-260">';
echo '<label for="rr_sort_by"><strong>Sort by</strong></label>';
@ -99,96 +84,83 @@ final class ReviewRecipientsAdminPage
echo '</div>';
echo '</div></div>';
echo '<div id="rr_status" class="feca-banner">';
echo 'Select a data source to load recipients.';
echo '</div>';
echo '<div id="rr_status" class="feca-banner">Select a data source to load recipients.</div>';
echo '<div id="rr_error" class="feca-banner feca-banner-error feca-hidden"></div>';
echo '<div class="feca-scroll-frame">';
echo '<div id="rr_scroll" class="feca-scroll-pane">';
echo '<div class="feca-scroll-frame"><div id="rr_scroll" class="feca-scroll-pane">';
echo '<table class="widefat striped feca-table-wide" id="rr_table">';
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>';
echo '</div></div>';
echo '<p class="feca-button-row"><button type="button" class="button" id="rr_load_more">Load More</button></p>';
$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,
],
'sources' => $sourceNames,
'pageSize' => self::DEFAULT_PAGE_SIZE,
], 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 storagePrefix="fecaReviewRecipientsSelection::";';
echo 'var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedKeys:{}};';
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.classList.add("feca-hidden");errEl.textContent="";}';
echo 'function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}';
echo 'function status(msg){statusEl.textContent=msg||"";}';
echo 'function setColumnWidths(cols){var widthPx=170;var c="<col style=\\"width:44px;min-width:44px;max-width:44px\\">";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 dedupeEquivalentColumns(cols,rows){if(!cols||cols.length<2||!rows||rows.length===0){return cols||[];}var keep=[];var signatures={};var limit=Math.min(rows.length,50);for(var i=0;i<cols.length;i++){var col=cols[i];var sig=[];for(var r=0;r<limit;r++){var row=rows[r]||{};sig.push(text(row[col]));}var sigKey=sig.join("\\u241f").toLowerCase();if(sigKey!==""&&signatures[sigKey]){continue;}signatures[sigKey]=col;keep.push(col);}return keep;}';
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 sourceStorageKey(src){return storagePrefix+String(src||"");}';
echo 'function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}';
echo 'function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}';
echo 'function detectKey(row){var r=row||{};if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}';
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||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=i;return out;});var cols=normalizeColumns(payload.columns||[],rows);cols=dedupeEquivalentColumns(cols,rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0)+" | Selected: 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 'setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\\"rr_select_all\\" type=\\"checkbox\\" title=\\"Select/Deselect all visible rows\\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);if(p.prefix){th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-prefix\\">"+p.prefix+"</span><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}else{th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.innerHTML="<input class=\\"rr-row-select\\" type=\\"checkbox\\" data-key=\\""+String(rowKey).replace(/"/g,"&quot;")+"\\""+(checked?" checked":"")+">";tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});';
echo 'var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+" | Selected: "+selectedInSource+(state.filter?(" | Filter: "+state.filter):""));saveSelection();}';
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;state.selectedKeys=loadSelection(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 'headRow.addEventListener("change",function(ev){var t=ev.target;if(!t||t.id!=="rr_select_all"){return;}var checked=!!t.checked;var nodes=bodyEl.querySelectorAll("input.rr-row-select[data-key]");nodes.forEach(function(node){var key=String(node.getAttribute("data-key")||"");if(!key){return;}if(checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}node.checked=checked;});renderTable();});';
echo 'bodyEl.addEventListener("change",function(ev){var t=ev.target;if(!t||!t.classList||!t.classList.contains("rr-row-select")){return;}var key=String(t.getAttribute("data-key")||"");if(!key){return;}if(t.checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}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 'state.selectedKeys=loadSelection(startSource);';
echo 'if(startSource){fetchSource(startSource);}else{renderTable();}';
echo '})();';
echo <<<'JS'
(function(){
var cfg=window.fecaReviewRecipientsConfig||{};
var sourceSel=document.getElementById("rr_data_source");
var filterInput=document.getElementById("rr_filter");
var sortSel=document.getElementById("rr_sort_by");
var dirSel=document.getElementById("rr_sort_direction");
var statusEl=document.getElementById("rr_status");
var errEl=document.getElementById("rr_error");
var colsEl=document.getElementById("rr_cols");
var headRow=document.getElementById("rr_head_row");
var bodyEl=document.getElementById("rr_body");
var loadMoreBtn=document.getElementById("rr_load_more");
var storagePrefix="fecaReviewRecipientsSelection::";
var selectedSourceStorageKey="fecaReviewRecipientsSelectedSource";
var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedKeys:{},rows:[],columns:[],count:0,offset:0,hasMore:false,loading:false,errors:[]};
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);}}
function clearErr(){errEl.classList.add("feca-hidden");errEl.textContent="";}
function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}
function status(msg){statusEl.textContent=msg||"";}
function setColumnWidths(cols){var widthPx=170;var c="<col style=\"width:44px;min-width:44px;max-width:44px\">";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;}
function sourceStorageKey(src){return storagePrefix+String(src||"");}
function sourceExists(src){if(!src){return false;}var opts=sourceSel?sourceSel.options:[];for(var i=0;i<opts.length;i++){if(String(opts[i].value||"")===src){return true;}}return false;}
function loadSelectedSource(){try{var src=String(sessionStorage.getItem(selectedSourceStorageKey)||"").trim();return sourceExists(src)?src:"";}catch(_){return "";}}
function saveSelectedSource(src){try{if(src){sessionStorage.setItem(selectedSourceStorageKey,src);}else{sessionStorage.removeItem(selectedSourceStorageKey);}}catch(_){}}
function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}
function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}
function isIdField(name){var n=String(name||"").trim().toLowerCase();return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}
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;}
function dedupeEquivalentColumns(cols,rows){if(!cols||cols.length<2||!rows||rows.length===0){return cols||[];}var keep=[];var signatures={};var limit=Math.min(rows.length,50);for(var i=0;i<cols.length;i++){var col=cols[i];var sig=[];for(var r=0;r<limit;r++){sig.push(text((rows[r]||{})[col]));}var sigKey=sig.join("\u241f").toLowerCase();if(sigKey!==""&&signatures[sigKey]){continue;}signatures[sigKey]=col;keep.push(col);}return keep;}
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;}
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)};}
function detectKey(row){var r=row||{};if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}
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);});}
function updateLoadMore(){if(!loadMoreBtn){return;}loadMoreBtn.disabled=state.loading||!state.hasMore||!state.source;loadMoreBtn.style.display=state.source&&state.hasMore?"inline-block":"none";}
function renderTable(){if(!state.source){clearErr();setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");updateLoadMore();return;}if(state.errors&&state.errors.length){showErr(state.errors.join("; "));setColumnWidths([]);headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");updateLoadMore();return;}clearErr();if(state.loading&&state.rows.length===0){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");updateLoadMore();return;}var rows=state.rows.slice();var cols=dedupeEquivalentColumns(normalizeColumns(state.columns,rows),rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows loaded: "+rows.length+" | Total from query: "+state.count+" | Selected: 0");rebuildSortColumns([], "");updateLoadMore();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;});}setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\"rr_select_all\" type=\"checkbox\" title=\"Select/Deselect all visible rows\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);var wrap=document.createElement("div");wrap.className="feca-rr-head-wrap";if(p.prefix){var pre=document.createElement("span");pre.className="feca-rr-head-prefix";pre.textContent=p.prefix;wrap.appendChild(pre);}var field=document.createElement("span");field.className="feca-rr-head-field";field.textContent=p.field;wrap.appendChild(field);th.appendChild(wrap);headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var cb=document.createElement("input");cb.className="rr-row-select";cb.type="checkbox";cb.setAttribute("data-key",rowKey);cb.checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.appendChild(cb);tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows loaded: "+rows.length+" | Visible: "+filtered.length+" | Total from query: "+state.count+" | Selected: "+selectedInSource+(state.hasMore?" | More rows available":"")+(state.filter?" | Filter: "+state.filter:""));saveSelection();updateLoadMore();}
function applyPayload(sourceName,j,append){var offset=parseInt(j.offset||0,10);var rows=(j.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=offset+i;return out;});state.source=sourceName;state.count=parseInt(j.count||0,10)||0;state.offset=offset+rows.length;state.hasMore=!!j.has_more;state.columns=append?Array.from(new Set(state.columns.concat(j.columns||[]))):(j.columns||[]);state.rows=append?state.rows.concat(rows):rows;}
function fetchSource(sourceName,append){if(!sourceName){state.source="";state.errors=[];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;renderTable();return;}clearErr();state.errors=[];state.loading=true;status(append?"Loading more recipients...":"Loading recipients...");updateLoadMore();var offset=append?state.offset:0;var limit=parseInt(cfg.pageSize||200,10)||200;var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName)+"&limit="+encodeURIComponent(String(limit))+"&offset="+encodeURIComponent(String(offset));fetch(url,{credentials:"same-origin"}).then(function(r){return r.text().then(function(raw){var j=null;try{j=JSON.parse(raw||"{}");}catch(_){j=null;}if(!r.ok){var errors=(j&&j.errors)||[(j&&j.error)||("API "+r.status)];return {ok:false,errors:errors,rows:[],columns:[],count:0};}if(j===null){return {ok:false,errors:["API returned non-JSON response."],rows:[],columns:[],count:0};}return j;});}).then(function(j){if(!j||j.ok===false){state.errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;}else{applyPayload(sourceName,j,!!append);}state.loading=false;renderTable();}).catch(function(e){state.loading=false;state.errors=[(e&&e.message)||"Request failed"];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;renderTable();});}
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());}
sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;state.errors=[];state.selectedKeys=loadSelection(s);state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;saveSelectedSource(s);updateUrlSource(s);fetchSource(s,false);});
filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});
sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});
dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});
if(loadMoreBtn){loadMoreBtn.addEventListener("click",function(){if(state.source&&state.hasMore&&!state.loading){fetchSource(state.source,true);}});}
headRow.addEventListener("change",function(ev){var t=ev.target;if(!t||t.id!=="rr_select_all"){return;}var checked=!!t.checked;var nodes=bodyEl.querySelectorAll("input.rr-row-select[data-key]");nodes.forEach(function(node){var key=String(node.getAttribute("data-key")||"");if(!key){return;}if(checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}node.checked=checked;});renderTable();});
bodyEl.addEventListener("change",function(ev){var t=ev.target;if(!t||!t.classList||!t.classList.contains("rr-row-select")){return;}var key=String(t.getAttribute("data-key")||"");if(!key){return;}if(t.checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}renderTable();});
state.sortDirection="asc";dirSel.value="asc";
var startSource=String((cfg.selectedSource||sourceSel.value||loadSelectedSource()||"")).trim();if(sourceExists(startSource)){sourceSel.value=startSource;}else{startSource="";}state.source=startSource;state.selectedKeys=loadSelection(startSource);if(startSource){saveSelectedSource(startSource);updateUrlSource(startSource);fetchSource(startSource,false);}else{renderTable();}
})();
JS;
echo '</script>';
echo '</div>';
}
public function handleApi(): void
{
$stage = 'initializing review recipients API';
$this->registerFatalJsonTrap('review recipients API', $stage);
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return;
}
@ -205,42 +177,111 @@ final class ReviewRecipientsAdminPage
return;
}
$sources = $this->service()->list();
$dsl = $this->dslForSource($sources, $sourceName);
$limit = max(1, min(self::MAX_PAGE_SIZE, $this->requestInt('limit', self::DEFAULT_PAGE_SIZE)));
$offset = max(0, $this->requestInt('offset', 0));
try {
$stage = 'loading selected data source';
$dsl = $this->dslForSource($sourceName);
if ($dsl === '') {
$this->wp->sendJson(['ok' => false, 'errors' => ['Selected data source was not found.']], 404);
return;
}
try {
$result = $this->service()->review($dsl);
$stage = 'querying recipient rows';
$result = $this->service()->review($dsl, $limit, $offset);
$errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
if ($errors !== []) {
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
return;
}
$this->wp->sendJson([
$stage = 'encoding recipient rows';
$payload = [
'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),
]);
'limit' => (int) ($result['limit'] ?? $limit),
'offset' => (int) ($result['offset'] ?? $offset),
'returned_count' => (int) ($result['returned_count'] ?? 0),
'has_more' => !empty($result['has_more']),
'diagnostics' => $this->diagnostics($stage),
];
$this->wp->sendJson($payload);
} catch (\Throwable $e) {
$this->wp->sendJson(['ok' => false, 'errors' => [$e->getMessage()]], 500);
$this->wp->sendJson([
'ok' => false,
'errors' => [$this->diagnosticError('Review Recipients API failed', $stage, $e)],
'diagnostics' => $this->diagnostics($stage),
], 500);
}
}
/** @param list<array<string,mixed>> $sources */
private function dslForSource(array $sources, string $sourceName): string
private function dslForSource(string $sourceName): string
{
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === $sourceName) {
return trim((string) ($source['dsl_text'] ?? ''));
$source = $this->service()->getByName($sourceName);
return is_array($source) ? trim((string) ($source['dsl_text'] ?? '')) : '';
}
/** @return array<string,mixed> */
private function diagnostics(string $stage): array
{
return [
'stage' => $stage,
'memory_usage' => function_exists('memory_get_usage') ? memory_get_usage(true) : null,
'memory_peak' => function_exists('memory_get_peak_usage') ? memory_get_peak_usage(true) : null,
];
}
return '';
private function registerFatalJsonTrap(string $context, string &$stage): void
{
if (!function_exists('register_shutdown_function')) {
return;
}
register_shutdown_function(function () use ($context, &$stage): void {
$error = error_get_last();
if (!is_array($error)) {
return;
}
$type = (int) ($error['type'] ?? 0);
if (!in_array($type, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true)) {
return;
}
$message = trim((string) ($error['message'] ?? 'Unknown fatal error.'));
$file = basename((string) ($error['file'] ?? 'unknown'));
$line = (int) ($error['line'] ?? 0);
$payload = [
'ok' => false,
'errors' => [
'Mailshots ' . $context . ' fatal error during ' . $stage . ': ' . $message . ' [' . $file . ':' . $line . ']',
],
'diagnostics' => $this->diagnostics($stage),
];
if (!headers_sent()) {
if (function_exists('status_header')) {
status_header(500);
} else {
http_response_code(500);
}
header('Content-Type: application/json; charset=UTF-8');
}
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
});
}
private function diagnosticError(string $prefix, string $stage, \Throwable $e): string
{
$message = $prefix . ' during ' . $stage . ': ' . $e->getMessage();
$file = $e->getFile();
$line = $e->getLine();
if ($file !== '' && $line > 0) {
$message .= ' [' . get_class($e) . ' at ' . basename($file) . ':' . $line . ']';
}
return $message;
}
private function service(): DataSourceService

View File

@ -126,7 +126,7 @@ final class RunMailshotAdminPage
echo '<div class="feca-control-row">';
echo '<div class="feca-control feca-control-min-360">';
echo '<label for="run_mailshot_id"><strong>Mailshot</strong></label>';
echo '<select id="run_mailshot_id" name="mailshot_id" onchange="document.getElementById(\'run-mailshot-picker\').submit();">';
echo '<select id="run_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0);
$sel = $id === $selectedMailshotId ? ' selected' : '';
@ -138,6 +138,16 @@ final class RunMailshotAdminPage
}
echo '</select></div></div>';
echo '</form>';
echo '<script>(function(){';
echo 'var select=document.getElementById("run_mailshot_id");';
echo 'var form=document.getElementById("run-mailshot-picker");';
echo 'var key="fecaMailshotsSelectedMailshotId";';
echo 'function hasOption(value){if(!select){return false;}for(var i=0;i<select.options.length;i++){if(String(select.options[i].value||"")===String(value||"")){return true;}}return false;}';
echo 'function currentUrl(){return new URL(window.location.href);}';
echo 'try{var url=currentUrl();var hasExplicit=url.searchParams.has("mailshot_id");var stored=String(sessionStorage.getItem(key)||"").trim();if(!hasExplicit&&stored&&hasOption(stored)&&select&&String(select.value||"")!==stored){url.searchParams.set("page","feca-mailshots-run");url.searchParams.set("mailshot_id",stored);window.location.replace(url.toString());return;}}catch(_){}';
echo 'if(select){try{if(select.value){sessionStorage.setItem(key,String(select.value));}}catch(_){}}';
echo 'if(select&&form){select.addEventListener("change",function(){try{sessionStorage.setItem(key,String(select.value||""));}catch(_){}form.submit();});}';
echo '})();</script>';
if (!empty($recipientSummary['ok'])) {
echo '<p class="feca-banner-note"><strong>Recipient rows:</strong> ' . (int) ($recipientSummary['count'] ?? 0) . '</p>';
} elseif (!empty($recipientSummary['errors']) && is_array($recipientSummary['errors'])) {

View File

@ -141,6 +141,7 @@ final class SetupAdminPage
$this->wp->updateOption(self::OPTION_KEY, $settings);
$this->wp->updateOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
$this->wp->deleteOption(\FecaMailshots\Infrastructure\MailshotSchemaInstaller::STATE_OPTION_KEY);
if (!headers_sent()) {
$location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&saved=1');

View File

@ -52,6 +52,12 @@ final class DataSourceService
return $this->queries->find($id);
}
/** @return array<string, mixed>|null */
public function getByName(string $name): ?array
{
return $this->queries->findByName($name);
}
/** @return array<string, mixed> */
public function validateDsl(string $dsl): array
{
@ -131,17 +137,19 @@ final class DataSourceService
public function preview(string $dsl, int $limit = 50): array
{
$limit = max(1, min(200, $limit));
return $this->queryRows($dsl, $limit);
return $this->queryRows($dsl, $limit, 0);
}
/** @return array<string, mixed> */
public function review(string $dsl): array
public function review(string $dsl, int $limit = 200, int $offset = 0): array
{
return $this->queryRows($dsl, null);
$limit = max(1, min(500, $limit));
$offset = max(0, $offset);
return $this->queryRows($dsl, $limit, $offset);
}
/** @return array<string, mixed> */
private function queryRows(string $dsl, ?int $limit): array
private function queryRows(string $dsl, int $limit, int $offset): array
{
$validation = $this->validateDsl($dsl);
if ($validation['errors'] !== []) {
@ -150,19 +158,17 @@ final class DataSourceService
$ast = $validation['ast'];
$compiled = $this->compiler->compile($ast);
$countCompiled = $this->compiler->compileCountable($ast);
$countSql = 'SELECT COUNT(*) FROM (' . $compiled['sql'] . ') AS q';
$countSql = 'SELECT COUNT(*) FROM (' . $countCompiled['sql'] . ') AS q';
$stmtCount = $this->router->membersPdo()->prepare($countSql);
$stmtCount->execute($compiled['params']);
$stmtCount->execute($countCompiled['params']);
$count = (int) $stmtCount->fetchColumn();
$previewSql = $compiled['sql'];
if ($limit !== null) {
$previewSql .= ' LIMIT ' . $limit;
}
$previewSql = $compiled['sql'] . ' LIMIT ' . $limit . ' OFFSET ' . $offset;
$stmtRows = $this->router->membersPdo()->prepare($previewSql);
$stmtRows->execute($compiled['params']);
$rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC);
$rows = $this->normalizeRowsForJson($stmtRows->fetchAll(PDO::FETCH_ASSOC));
$columnSet = [];
foreach ($rows as $row) {
@ -178,9 +184,44 @@ final class DataSourceService
'rows' => $rows,
'columns' => array_keys($columnSet),
'expected_fields' => $validation['expected_fields'],
'limit' => $limit,
'offset' => $offset,
'returned_count' => count($rows),
'has_more' => ($offset + count($rows)) < $count,
];
}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
private function normalizeRowsForJson(array $rows): array
{
foreach ($rows as &$row) {
foreach ($row as $key => $value) {
if (is_string($value)) {
$row[$key] = $this->jsonSafeString($value);
}
}
}
unset($row);
return $rows;
}
private function jsonSafeString(string $value): string
{
if ($value === '' || preg_match('//u', $value) === 1) {
return $value;
}
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($value, 'UTF-8', 'UTF-8');
}
if (function_exists('iconv')) {
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
if (is_string($converted)) {
return $converted;
}
}
return '';
}
/** @return array<string, list<string>> */
public function sourceFields(): array
{
@ -191,6 +232,12 @@ final class DataSourceService
return $result;
}
/** @return list<string> */
public function knownSources(): array
{
return $this->metadata->allKnownSources();
}
/** @return list<string> */
public function listSchemas(): array
{

View File

@ -20,6 +20,24 @@ final class DslCompiler
* @return array{sql:string,params:list<mixed>}
*/
public function compile(array $ast): array
{
return $this->compileWithProjection($ast, null);
}
/**
* @param array{sources:list<string>, where:array<int, mixed>} $ast
* @return array{sql:string,params:list<mixed>}
*/
public function compileCountable(array $ast): array
{
return $this->compileWithProjection($ast, '1');
}
/**
* @param array{sources:list<string>, where:array<int, mixed>} $ast
* @return array{sql:string,params:list<mixed>}
*/
private function compileWithProjection(array $ast, ?string $selectSql): array
{
$sources = $ast['sources'];
if ($sources === []) {
@ -64,7 +82,7 @@ final class DslCompiler
// Always emit source-qualified projection keys so token names remain stable
// without any post-query alias fallback.
$selectSql = $this->buildUniqueSelectProjection($sources);
$selectSql = $selectSql ?? $this->buildUniqueSelectProjection($sources);
$sql = 'SELECT ' . $selectSql . ' FROM ' . $from;
if ($joins !== []) {
$sql .= ' ' . implode(' ', $joins);
@ -113,7 +131,7 @@ final class DslCompiler
$value = '%' . $value;
}
$params[] = $value;
$sql = sprintf('%s %s ?', $lhs, $op);
$sql = sprintf('%s %s ?', $this->nullAsEmptySql($lhs), $op);
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
}
@ -126,7 +144,12 @@ final class DslCompiler
if ($predicate['type'] === 'comparison_field') {
$lhs = $this->fieldRefSql($predicate['lhs']);
$rhs = $this->fieldRefSql($predicate['rhs']);
$sql = sprintf('%s %s %s', $lhs, $predicate['op'], $rhs);
$sql = sprintf(
'%s %s %s',
$this->nullAsEmptySql($lhs),
$predicate['op'],
$this->nullAsEmptySql($rhs)
);
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
}
@ -161,6 +184,11 @@ final class DslCompiler
return '(' . $fieldSql . ' IS NULL OR ' . $fieldSql . " = '')";
}
private function nullAsEmptySql(string $fieldSql): string
{
return 'COALESCE(' . $fieldSql . ", '')";
}
/** @param list<mixed> $args @param list<mixed> &$params @param list<string> $sources */
private function compileFilter(string $name, array $args, array &$params, array $sources): string
{
@ -176,6 +204,12 @@ final class DslCompiler
if ($name === 'primary-contact') {
return $this->alias('contacts') . '.`is_contact_1` = 1';
}
if ($name === 'member-account') {
return $this->compileAccountTypeSlugFilter('member');
}
if ($name === 'affiliate-account') {
return $this->compileAccountTypeSlugFilter('affiliate');
}
if ($name === 'selected') {
return 'COALESCE(' . $this->alias('advertisers') . '.`Selected`, 0) <> 0';
}
@ -210,11 +244,16 @@ final class DslCompiler
throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]);
}
private function compileAccountTypeSlugFilter(string $slug): string
{
return $this->alias('accounts') . '.`account_type_id` IN (SELECT id FROM `picklist_account_type` WHERE LOWER(TRIM(COALESCE(`slug`, \'\'))) = \'' . $slug . '\')';
}
/** @return array{string,string} */
private function rewriteJoinRef(string $left, string $right): array
{
$leftParts = explode('.', $left, 2);
$rightParts = explode('.', $right, 2);
$leftParts = $this->splitJoinRef($left);
$rightParts = $this->splitJoinRef($right);
return [
$this->alias($leftParts[0]) . '.`' . str_replace('`', '``', $leftParts[1]) . '`',
@ -222,6 +261,17 @@ final class DslCompiler
];
}
/** @return array{string,string} */
private function splitJoinRef(string $reference): array
{
$separator = strrpos($reference, '.');
if ($separator === false || $separator === 0 || $separator === strlen($reference) - 1) {
throw new AppError('dsl_compile', 'Invalid join field reference', ['reference' => $reference]);
}
return [substr($reference, 0, $separator), substr($reference, $separator + 1)];
}
/** @param array{source:string,field:string} $fieldRef */
private function fieldRefSql(array $fieldRef): string
{

View File

@ -16,6 +16,8 @@ final class DslValidator
'pending-renewal' => ['renewals'],
'primary-contact' => ['contacts'],
'fen1-contact' => ['contacts'],
'member-account' => ['accounts'],
'affiliate-account' => ['accounts'],
'selected' => ['advertisers'],
'issue' => ['advertisers|ads|pages|articles|issues|invoices'],
'pending-invoice' => ['invoices'],
@ -52,10 +54,10 @@ final class DslValidator
$errors[] = 'Unknown source: ' . $source;
continue;
}
if (str_contains($source, '.')) {
$hasCustom = true;
} else {
if ($this->metadata->isBuiltInSource($source)) {
$hasBuiltIn = true;
} else {
$hasCustom = true;
}
}
@ -156,7 +158,7 @@ final class DslValidator
{
$name = (string) ($predicate['name'] ?? '');
$args = is_array($predicate['args'] ?? null) ? $predicate['args'] : [];
if (in_array($name, ['selected-renewal', 'pending-renewal', 'primary-contact', 'fen1-contact', 'selected', 'pending-invoice', 'selected-invoice', 'member-or-affiliate-or-parish-council'], true)) {
if (in_array($name, ['selected-renewal', 'pending-renewal', 'primary-contact', 'fen1-contact', 'member-account', 'affiliate-account', 'selected', 'pending-invoice', 'selected-invoice', 'member-or-affiliate-or-parish-council'], true)) {
if ($args !== []) {
$errors[] = sprintf('Filter %s does not take arguments', $name);
}

View File

@ -11,6 +11,9 @@ use FecaMailshots\Repository\MailshotRepository;
final class MailshotRunService
{
private const DEFAULT_MAX_ESTIMATED_MIME_BYTES = 26214400;
private const ESTIMATED_MIME_WARNING_RATIO = 0.75;
private MailshotRepository $mailshots;
private MailshotQueryRepository $queries;
private AttachmentRepository $attachments;
@ -54,12 +57,12 @@ final class MailshotRunService
public function previewRecipients(int $mailshotId, int $limit = 100): array
{
try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
$limit = max(1, min(500, $limit));
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $limit);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []];
}
$limit = max(1, min(500, $limit));
$rows = array_slice($rows, 0, $limit);
$out = [];
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
@ -112,7 +115,8 @@ final class MailshotRunService
public function renderTest(int $mailshotId, int $recipientIndex): array
{
try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
$rowLimit = max(1, min(5000, $recipientIndex + 1));
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $rowLimit);
if (!isset($rows[$recipientIndex])) {
return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
}
@ -165,6 +169,10 @@ final class MailshotRunService
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, (array) ($render['recipient'] ?? []), (array) ($render['rendered'] ?? []))
);
$messageSize = $this->messageSizeDiagnostics((string) $render['rendered']['subject'], (string) $render['rendered']['message'], $attachments);
if (!$messageSize['ok']) {
return ['ok' => false, 'errors' => $messageSize['errors'], 'message_size' => $messageSize];
}
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
@ -189,7 +197,8 @@ final class MailshotRunService
return [
'ok' => true,
'warnings' => $warnings,
'warnings' => array_merge($warnings, $messageSize['warnings']),
'message_size' => $messageSize,
'sent_to' => $testEmail,
'sent_at' => gmdate('c'),
];
@ -242,6 +251,11 @@ final class MailshotRunService
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, (array) $row, $render)
);
$rowStage = 'checking estimated message size';
$messageSize = $this->messageSizeDiagnostics((string) $render['subject'], (string) $render['message'], $attachments);
if (!$messageSize['ok']) {
throw new \RuntimeException(implode('; ', $messageSize['errors']));
}
$rowStage = 'sending SMTP message';
$send = $this->smtp->send(
$creds,
@ -492,6 +506,7 @@ final class MailshotRunService
}
$files = [];
$filesTmpDir = '';
$ghostscriptBinary = $this->findExecutableBinary('gs');
if ($includeMerged && $ghostscriptBinary === '') {
return ['ok' => false, 'errors' => ['Merged PDF generation requires Ghostscript (gs) to be installed and available on PATH.']];
@ -513,6 +528,13 @@ final class MailshotRunService
return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for merged PDF build.']];
}
}
if ($includeFiles) {
$filesTmpDir = sys_get_temp_dir() . '/feca_mailshots_pdf_files_' . str_replace('.', '_', uniqid('', true));
if (!@mkdir($filesTmpDir, 0700, true) && !is_dir($filesTmpDir)) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for generated PDF files.']];
}
}
foreach (array_values($rows) as $index => $row) {
try {
@ -532,10 +554,16 @@ final class MailshotRunService
$baseName = $this->pdfFilename($mailshot, (array) $row);
$filename = $this->uniqueFilename($baseName, $nameCounts);
$pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
$pdfPath = $filesTmpDir . '/' . $filename;
if (@file_put_contents($pdfPath, $pdfBytes) === false) {
throw new \RuntimeException('Unable to write generated PDF file.');
}
$files[] = [
'filename' => $filename,
'content_bytes' => $pdfBytes,
'path' => $pdfPath,
'size' => strlen($pdfBytes),
];
unset($pdfBytes);
}
if ($useGhostscriptMerge) {
$pdfBytesForMerge = $this->renderPdfBytesFromHtml($pdfHtml);
@ -559,10 +587,17 @@ final class MailshotRunService
if ($errors !== []) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
$this->cleanupGeneratedFileRows($files);
if ($filesTmpDir !== '') {
@rmdir($filesTmpDir);
}
return ['ok' => false, 'errors' => $errors];
}
if ($includeFiles && $files === []) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
if ($filesTmpDir !== '') {
@rmdir($filesTmpDir);
}
return ['ok' => false, 'errors' => ['No PDF attachments were generated from this mailshot.']];
}
if ($includeMerged && $mergedSectionCount === 0) {
@ -570,12 +605,19 @@ final class MailshotRunService
return ['ok' => false, 'errors' => ['No merged PDF content was generated from this mailshot.']];
}
$mergedPdfBytes = '';
$mergedPdfPath = '';
$mergedPdfSize = 0;
if ($includeMerged) {
try {
$mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
$mergedPdfPath = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
$mergedPdfSizeRaw = @filesize($mergedPdfPath);
$mergedPdfSize = is_int($mergedPdfSizeRaw) ? $mergedPdfSizeRaw : 0;
} catch (\Throwable $e) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
$this->cleanupGeneratedFileRows($files);
if ($filesTmpDir !== '') {
@rmdir($filesTmpDir);
}
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
}
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
@ -587,7 +629,9 @@ final class MailshotRunService
'generated_count' => $includeFiles ? count($files) : $mergedSectionCount,
'skipped_count' => $skipped,
'files' => $files,
'merged_pdf_bytes' => $mergedPdfBytes,
'files_dir' => $filesTmpDir,
'merged_pdf_path' => $mergedPdfPath,
'merged_pdf_size' => $mergedPdfSize,
];
}
@ -775,7 +819,7 @@ final class MailshotRunService
}
/** @return array{0:array<string,mixed>,1:list<array<string,mixed>>} */
private function loadMailshotAndRows(int $mailshotId): array
private function loadMailshotAndRows(int $mailshotId, int $rowLimit = 5000): array
{
$mailshot = $this->mailshots->find($mailshotId);
if ($mailshot === null) {
@ -793,7 +837,7 @@ final class MailshotRunService
throw new \RuntimeException('Data source DSL is empty.');
}
$preview = $this->dataSources->preview($dsl, 5000);
$preview = $this->dataSources->preview($dsl, $rowLimit);
if (($preview['errors'] ?? []) !== []) {
throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors']));
}
@ -872,6 +916,11 @@ final class MailshotRunService
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, $row, $render)
);
$stage = 'checking estimated message size for recipient ' . $recipientKey;
$messageSize = $this->messageSizeDiagnostics((string) $render['subject'], (string) $render['message'], $attachments);
if (!$messageSize['ok']) {
throw new \RuntimeException(implode('; ', $messageSize['errors']));
}
$stage = 'sending SMTP message for recipient ' . $recipientKey;
$smtp = $this->smtp->send(
$creds,
@ -1138,13 +1187,13 @@ final class MailshotRunService
);
}
$mergedBytes = @file_get_contents($outputPdfPath);
$mergedSize = @filesize($outputPdfPath);
if (!is_int($mergedSize) || $mergedSize <= 0) {
@unlink($outputPdfPath);
if (!is_string($mergedBytes) || $mergedBytes === '') {
throw new \RuntimeException('Ghostscript merge produced an empty output file.');
}
return $mergedBytes;
return $outputPdfPath;
}
/** @param array<string,mixed> $mailshot @param array<string,mixed> $row */
@ -1283,6 +1332,78 @@ final class MailshotRunService
return $out;
}
/**
* @param list<array{filename:string,mime_type:string,content_bytes:string}> $attachments
* @return array{ok:bool,estimated_mime_bytes:int,raw_attachment_bytes:int,max_estimated_mime_bytes:int,warnings:list<string>,errors:list<string>}
*/
private function messageSizeDiagnostics(string $subject, string $htmlBody, array $attachments): array
{
$rawAttachmentBytes = 0;
foreach ($attachments as $attachment) {
$rawAttachmentBytes += strlen((string) ($attachment['content_bytes'] ?? ''));
}
$estimatedMimeBytes = strlen($subject) + strlen($htmlBody) + 4096;
foreach ($attachments as $attachment) {
$bytes = strlen((string) ($attachment['content_bytes'] ?? ''));
$estimatedMimeBytes += (int) ceil($bytes * 1.37) + 1024 + strlen((string) ($attachment['filename'] ?? ''));
}
$maxBytes = $this->maxEstimatedMimeBytes();
$warnings = [];
$errors = [];
if ($estimatedMimeBytes > $maxBytes) {
$errors[] = 'Estimated message size is ' . $this->formatBytes($estimatedMimeBytes)
. ', above the configured send limit of ' . $this->formatBytes($maxBytes) . '.';
} elseif ($estimatedMimeBytes >= (int) floor($maxBytes * self::ESTIMATED_MIME_WARNING_RATIO)) {
$warnings[] = 'Estimated message size is ' . $this->formatBytes($estimatedMimeBytes)
. ', close to the configured send limit of ' . $this->formatBytes($maxBytes) . '.';
}
return [
'ok' => $errors === [],
'estimated_mime_bytes' => $estimatedMimeBytes,
'raw_attachment_bytes' => $rawAttachmentBytes,
'max_estimated_mime_bytes' => $maxBytes,
'warnings' => $warnings,
'errors' => $errors,
];
}
private function maxEstimatedMimeBytes(): int
{
$envValue = getenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES');
if (is_string($envValue) && trim($envValue) !== '' && ctype_digit(trim($envValue))) {
$value = (int) trim($envValue);
if ($value > 0) {
return $value;
}
}
return self::DEFAULT_MAX_ESTIMATED_MIME_BYTES;
}
private function formatBytes(int $bytes): string
{
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 1) . ' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 1) . ' KB';
}
return (string) $bytes . ' bytes';
}
/** @param list<array<string,mixed>> $files */
private function cleanupGeneratedFileRows(array $files): void
{
foreach ($files as $file) {
$path = (string) ($file['path'] ?? '');
if ($path !== '') {
@unlink($path);
}
}
}
private function inferMimeTypeFromFilename(string $fileName): string
{
$fileName = trim($fileName);

View File

@ -37,11 +37,16 @@ final class MailshotService
return $this->mailshots->all();
}
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
return $this->mailshots->find($id);
}
/** @return list<string> */
public function dataSourceNames(): array
{
$rows = $this->queries->all();
return array_map(static fn(array $r): string => (string) $r['name'], $rows);
return $this->queries->names();
}
/** @return list<string> */

View File

@ -8,6 +8,8 @@ interface SourceMetadataProvider
{
public function sourceExists(string $source): bool;
public function isBuiltInSource(string $source): bool;
/** @return list<string> */
public function sourceFields(string $source): array;

View File

@ -156,19 +156,23 @@ final class DslParser
if ($this->match('.')) {
$ident .= '.' . $field;
$field = $this->expect('IDENT')->value;
} else {
$ident = strtolower($ident);
}
return ['source' => strtolower($ident), 'field' => $field];
return ['source' => $ident, 'field' => $field];
}
/** @return array{source:string, field:string} */
private function parseFieldRef(): array
{
$src = strtolower($this->expect('IDENT')->value);
$src = $this->expect('IDENT')->value;
$this->expect('.');
$field = $this->expect('IDENT')->value;
if ($this->match('.')) {
$src .= '.' . strtolower($field);
$src .= '.' . $field;
$field = $this->expect('IDENT')->value;
} else {
$src = strtolower($src);
}
return ['source' => $src, 'field' => $field];
}
@ -260,6 +264,8 @@ final class DslParser
'selected',
'primary-contact',
'fen1-contact',
'member-account',
'affiliate-account',
'pending-invoice',
'selected-invoice',
'member-or-affiliate-or-parish-council',

View File

@ -1,225 +0,0 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
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 $attachments = []): array
{
$host = (string) ($credentials['smtp_host'] ?? '');
$port = (int) ($credentials['smtp_port'] ?? 0);
$user = (string) ($credentials['smtp_user'] ?? '');
$pass = (string) ($credentials['smtp_password'] ?? '');
$from = (string) ($credentials['smtp_from_email'] ?? '');
$fromName = (string) ($credentials['smtp_from_name'] ?? '');
if ($host === '' || $port <= 0 || $user === '' || $pass === '' || $from === '') {
throw new \RuntimeException('Missing SMTP credentials.');
}
$transport = !empty($credentials['smtp_require_tls']) ? 'tls://' : '';
$fp = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 20);
if (!is_resource($fp)) {
throw new \RuntimeException('SMTP connect failed: ' . $errstr);
}
try {
$this->expect($fp, [220]);
$this->cmd($fp, 'EHLO localhost', [250]);
if (empty($credentials['smtp_require_tls'])) {
// try opportunistic STARTTLS
$line = $this->cmd($fp, 'STARTTLS', [220], false);
if ($line !== null) {
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('Failed to enable STARTTLS crypto.');
}
$this->cmd($fp, 'EHLO localhost', [250]);
}
}
$this->cmd($fp, 'AUTH LOGIN', [334]);
$this->cmd($fp, base64_encode($user), [334]);
$this->cmd($fp, base64_encode($pass), [235]);
$this->cmd($fp, 'MAIL FROM:<' . $from . '>', [250]);
$allRecipients = array_values(array_unique(array_merge($to, $cc, $bcc)));
foreach ($allRecipients as $recipient) {
$this->cmd($fp, 'RCPT TO:<' . trim($recipient) . '>', [250, 251]);
}
$this->cmd($fp, 'DATA', [354]);
$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);
return ['raw_mime' => $raw];
} finally {
fclose($fp);
}
}
/** @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, array $attachments): string
{
$headers = [];
$fromHeader = $fromName !== '' ? sprintf('%s <%s>', $this->encodeHeaderValue($fromName), $from) : $from;
$headers[] = 'From: ' . $fromHeader;
$headers[] = 'To: ' . implode(', ', $to);
if ($cc !== []) {
$headers[] = 'Cc: ' . implode(', ', $cc);
}
if ($bcc !== []) {
$headers[] = 'Bcc: ' . implode(', ', $bcc);
}
if ($replyTo !== null && trim($replyTo) !== '') {
$headers[] = 'Reply-To: ' . trim($replyTo);
}
$headers[] = 'Subject: ' . $this->encodeHeaderValue($subject);
$headers[] = 'MIME-Version: 1.0';
if ($attachments === []) {
$headers[] = 'Content-Type: text/html; charset=UTF-8';
return implode("\r\n", $headers) . "\r\n\r\n" . $htmlBody;
}
$boundary = 'feca_mailshots_' . bin2hex(random_bytes(12));
$mime = implode("\r\n", array_merge($headers, ['Content-Type: multipart/mixed; boundary="' . $boundary . '"'])) . "\r\n\r\n";
$mime .= '--' . $boundary . "\r\n";
$mime .= 'Content-Type: text/html; charset=UTF-8' . "\r\n";
$mime .= 'Content-Transfer-Encoding: 8bit' . "\r\n\r\n";
$mime .= $htmlBody . "\r\n";
foreach ($attachments as $attachment) {
$filename = trim((string) ($attachment['filename'] ?? 'attachment.bin'));
$mimeType = trim((string) ($attachment['mime_type'] ?? 'application/octet-stream'));
$bytes = (string) ($attachment['content_bytes'] ?? '');
if ($filename === '' || $bytes === '') {
continue;
}
$escapedFilename = addcslashes($filename, '"\\');
$mime .= '--' . $boundary . "\r\n";
$mime .= 'Content-Type: ' . $mimeType . '; name="' . $escapedFilename . '"' . "\r\n";
$mime .= 'Content-Transfer-Encoding: base64' . "\r\n";
$mime .= 'Content-Disposition: attachment; filename="' . $escapedFilename . '"' . "\r\n\r\n";
$mime .= $this->encodeBase64Chunked($bytes) . "\r\n";
}
$mime .= '--' . $boundary . '--' . "\r\n";
return $mime;
}
private function encodeHeaderValue(string $value): string
{
$value = preg_replace('/[\r\n]+/', ' ', $value);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', is_string($value) ? $value : '');
$value = trim(preg_replace('/[ \t]+/', ' ', is_string($value) ? $value : '') ?? '');
if ($value === '') {
return '';
}
$chars = [];
if (preg_match_all('/./us', $value, $matches) === 1) {
$chars = $matches[0];
} else {
$chars = str_split($value);
}
$chunks = [];
$chunk = '';
foreach ($chars as $char) {
if ($chunk !== '' && strlen($chunk . $char) > 45) {
$chunks[] = $chunk;
$chunk = '';
}
$chunk .= $char;
}
if ($chunk !== '') {
$chunks[] = $chunk;
}
$encoded = array_map(static fn(string $chunk): string => '=?UTF-8?B?' . base64_encode($chunk) . '?=', $chunks);
return implode("\r\n ", $encoded);
}
private function encodeBase64Chunked(string $bytes): string
{
$stream = fopen('php://temp', 'w+b');
if (!is_resource($stream)) {
throw new \RuntimeException('Unable to allocate temp stream for attachment encoding.');
}
try {
if (fwrite($stream, $bytes) === false) {
throw new \RuntimeException('Failed writing attachment bytes to temp stream.');
}
rewind($stream);
$filter = stream_filter_append(
$stream,
'convert.base64-encode',
STREAM_FILTER_READ,
['line-length' => 76, 'line-break-chars' => "\r\n"]
);
if ($filter === false) {
throw new \RuntimeException('Failed to initialize base64 stream filter.');
}
$encoded = stream_get_contents($stream);
if (!is_string($encoded)) {
throw new \RuntimeException('Failed to read encoded attachment bytes.');
}
return rtrim($encoded, "\r\n");
} finally {
fclose($stream);
}
}
/** @param list<int> $codes */
private function cmd($fp, string $cmd, array $codes, bool $throwOnMismatch = true): ?string
{
fwrite($fp, $cmd . "\r\n");
return $this->expect($fp, $codes, $throwOnMismatch);
}
/** @param list<int> $codes */
private function expect($fp, array $codes, bool $throwOnMismatch = true): ?string
{
$lastLine = null;
while (true) {
$line = fgets($fp, 4096);
if ($line === false) {
if ($throwOnMismatch) {
throw new \RuntimeException('SMTP read failed.');
}
return null;
}
$lastLine = $line;
if (!preg_match('/^(\d{3})([\s-])/', $line, $m)) {
if ($throwOnMismatch) {
throw new \RuntimeException('SMTP malformed response: ' . trim($line));
}
return null;
}
$code = (int) $m[1];
if (!in_array($code, $codes, true)) {
if ($throwOnMismatch) {
throw new \RuntimeException('SMTP unexpected response: ' . trim($line));
}
return null;
}
$continuation = $m[2] === '-';
if (!$continuation) {
return $lastLine;
}
}
}
}

View File

@ -24,7 +24,8 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
{
$this->router = $router;
$fen = $router->fenDbName();
$this->builtInSources = ['contacts', 'accounts', 'renewals', 'grants', 'advertisers', 'ads', 'pages', 'articles', 'issues', 'invoices'];
$adSizes = 'ad_sizes';
$this->builtInSources = ['contacts', 'accounts', 'renewals', 'grants', 'advertisers', 'ads', 'pages', 'articles', 'issues', 'invoices', $adSizes];
$this->builtInTables = [
'contacts' => 'contacts',
@ -37,6 +38,7 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
'articles' => $fen . '.Articles',
'issues' => $fen . '.Issues',
'invoices' => $fen . '.invoices',
$adSizes => $fen . '.Ad_Sizes',
];
$this->joinMap = [
@ -62,6 +64,8 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
'issues|invoices' => ['left' => 'issues.ID', 'right' => 'invoices.issue_id'],
'invoices|pages' => ['left' => 'invoices.issue_id', 'right' => 'pages.Issue'],
'pages|invoices' => ['left' => 'pages.Issue', 'right' => 'invoices.issue_id'],
'ads|' . $adSizes => ['left' => 'ads.AdSize', 'right' => $adSizes . '.SizeName'],
$adSizes . '|ads' => ['left' => $adSizes . '.SizeName', 'right' => 'ads.AdSize'],
];
}
@ -86,6 +90,11 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
return false;
}
public function isBuiltInSource(string $source): bool
{
return in_array($source, $this->builtInSources, true);
}
public function sourceFields(string $source): array
{
if (in_array($source, $this->builtInSources, true)) {

View File

@ -8,6 +8,9 @@ use PDO;
final class MailshotSchemaInstaller
{
public const SCHEMA_VERSION = '1';
public const STATE_OPTION_KEY = 'feca_mailshots_schema_state';
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
@ -26,6 +29,16 @@ final class MailshotSchemaInstaller
$this->ensureMailshotQueriesColumns($pdo);
}
/** @param array<string,string> $dbConfig @return array{version:string,config_hash:string} */
public static function migrationTarget(array $dbConfig): array
{
ksort($dbConfig);
return [
'version' => self::SCHEMA_VERSION,
'config_hash' => hash('sha256', json_encode($dbConfig, JSON_UNESCAPED_SLASHES) ?: ''),
];
}
/** @return list<string> */
private function createTableSql(): array
{

View File

@ -87,8 +87,9 @@ final class PdoDatabaseRouter implements DatabaseRouter
private function connect(string $dbName): PDO
{
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName);
return new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName),
$dsn,
$this->user,
$this->pass,
$this->pdoOptions

View File

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use PDO;
final class SchemaEnsuringDatabaseRouter implements DatabaseRouter
{
private DatabaseRouter $inner;
private bool $installed = false;
public function __construct(DatabaseRouter $inner)
{
$this->inner = $inner;
}
public function mailshotsPdo(): PDO
{
$this->installOnce();
return $this->inner->mailshotsPdo();
}
public function membersPdo(): PDO
{
return $this->inner->membersPdo();
}
public function mailshotsDbName(): string
{
return $this->inner->mailshotsDbName();
}
public function membersDbName(): string
{
return $this->inner->membersDbName();
}
public function fenDbName(): string
{
return $this->inner->fenDbName();
}
private function installOnce(): void
{
if ($this->installed) {
return;
}
(new MailshotSchemaInstaller($this->inner))->install();
$this->installed = true;
}
}

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SmtpSender;
final class WordPressSmtpSender implements SmtpSender
{
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array
{
if (!function_exists('wp_mail') || !function_exists('add_action')) {
throw new \RuntimeException('WordPress mail service is unavailable.');
}
$host = trim((string) ($credentials['smtp_host'] ?? ''));
$port = (int) ($credentials['smtp_port'] ?? 0);
$user = trim((string) ($credentials['smtp_user'] ?? ''));
$pass = (string) ($credentials['smtp_password'] ?? '');
$from = trim((string) ($credentials['smtp_from_email'] ?? ''));
$fromName = trim((string) ($credentials['smtp_from_name'] ?? ''));
if ($host === '' || $port <= 0 || $user === '' || $pass === '' || $from === '') {
throw new \RuntimeException('Missing SMTP credentials.');
}
$ehloDomain = $this->mailDomainFromAddress($from);
$capturedMailer = null;
$configureMailer = function ($phpmailer) use ($host, $port, $user, $pass, $from, $fromName, $ehloDomain, $credentials, $attachments, &$capturedMailer): void {
$capturedMailer = $phpmailer;
if (method_exists($phpmailer, 'isSMTP')) {
$phpmailer->isSMTP();
}
$phpmailer->Host = $host;
$phpmailer->Port = $port;
$phpmailer->SMTPAuth = true;
$phpmailer->Username = $user;
$phpmailer->Password = $pass;
$phpmailer->Helo = $ehloDomain;
$phpmailer->CharSet = 'UTF-8';
$phpmailer->MessageID = '<' . gmdate('YmdHis') . '.' . bin2hex(random_bytes(16)) . '@' . $ehloDomain . '>';
if (!empty($credentials['smtp_require_tls'])) {
$phpmailer->SMTPSecure = defined('\PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS')
? \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS
: 'ssl';
} else {
$phpmailer->SMTPAutoTLS = true;
}
if (method_exists($phpmailer, 'setFrom')) {
$phpmailer->setFrom($from, $fromName, false);
}
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;
}
if (!method_exists($phpmailer, 'addStringAttachment')) {
throw new \RuntimeException('WordPress mail service does not support in-memory attachments.');
}
$phpmailer->addStringAttachment($bytes, $filename, 'base64', $mimeType);
}
};
add_action('phpmailer_init', $configureMailer);
try {
$headers = $this->headers($from, $fromName, $cc, $bcc, $replyTo);
$ok = wp_mail($to, $subject, $htmlBody, $headers);
} finally {
if (function_exists('remove_action')) {
remove_action('phpmailer_init', $configureMailer);
}
}
if ($ok !== true) {
$error = is_object($capturedMailer) && isset($capturedMailer->ErrorInfo) && (string) $capturedMailer->ErrorInfo !== ''
? ': ' . (string) $capturedMailer->ErrorInfo
: '.';
throw new \RuntimeException('WordPress mail send failed' . $error);
}
$rawMime = '';
if (is_object($capturedMailer) && method_exists($capturedMailer, 'getSentMIMEMessage')) {
$rawMime = (string) $capturedMailer->getSentMIMEMessage();
}
if ($rawMime === '') {
throw new \RuntimeException('WordPress mail send succeeded but raw MIME was unavailable for Sent-folder append.');
}
return ['raw_mime' => $rawMime];
}
/** @param list<string> $cc @param list<string> $bcc */
private function headers(string $from, string $fromName, array $cc, array $bcc, ?string $replyTo): array
{
$headers = [
'Content-Type: text/html; charset=UTF-8',
'From: ' . ($fromName !== '' ? sprintf('%s <%s>', $fromName, $from) : $from),
];
foreach ($cc as $address) {
$address = trim($address);
if ($address !== '') {
$headers[] = 'Cc: ' . $address;
}
}
foreach ($bcc as $address) {
$address = trim($address);
if ($address !== '') {
$headers[] = 'Bcc: ' . $address;
}
}
$replyTo = trim((string) $replyTo);
if ($replyTo !== '') {
$headers[] = 'Reply-To: ' . $replyTo;
}
return $headers;
}
private function mailDomainFromAddress(string $address): string
{
$address = trim($address);
$at = strrpos($address, '@');
if ($at === false) {
throw new \RuntimeException('SMTP From email must contain a domain for SMTP EHLO.');
}
$domain = strtolower(trim(substr($address, $at + 1), " \t\r\n.<>"));
if ($domain === '' || strlen($domain) > 253) {
throw new \RuntimeException('SMTP From email domain is invalid.');
}
$labels = explode('.', $domain);
foreach ($labels as $label) {
if (
$label === ''
|| strlen($label) > 63
|| preg_match('/[^a-z0-9-]/', $label) === 1
|| str_starts_with($label, '-')
|| str_ends_with($label, '-')
) {
throw new \RuntimeException('SMTP From email domain is invalid.');
}
}
return $domain;
}
}

View File

@ -26,13 +26,12 @@ use FecaMailshots\Application\PdfAssetService;
use FecaMailshots\Application\SecretKeyProvider;
use FecaMailshots\Application\TemplateRenderer;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\BasicSmtpSender;
use FecaMailshots\Infrastructure\DatabaseRouter;
use FecaMailshots\Infrastructure\MailshotSchemaInstaller;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\PhpImapAppender;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Infrastructure\SchemaEnsuringDatabaseRouter;
use FecaMailshots\Infrastructure\WordPressSmtpSender;
use FecaMailshots\Infrastructure\WordPressSaltSecretKeyProvider;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\LastRunRepository;
@ -54,7 +53,7 @@ final class Plugin
$c->set('logger', static fn() => new ErrorLogLogger());
$c->set(DatabaseRouter::class, static fn() => $router ?? new SchemaEnsuringDatabaseRouter(new PdoDatabaseRouter($dbConfig)));
$c->set(DatabaseRouter::class, static fn() => $router ?? new PdoDatabaseRouter($dbConfig));
$c->set(MailshotSchemaInstaller::class, static fn(Container $c) => new MailshotSchemaInstaller($c->get(DatabaseRouter::class)));
$c->set(DatabaseSourceMetadataProvider::class, static fn(Container $c) => new DatabaseSourceMetadataProvider($c->get(DatabaseRouter::class)));
@ -99,7 +98,7 @@ final class Plugin
return $c->get(PdfAssetRepository::class)->findByName($name);
}
));
$c->set(SmtpSender::class, static fn() => new BasicSmtpSender());
$c->set(SmtpSender::class, static fn() => new WordPressSmtpSender());
$c->set(ImapAppender::class, static fn() => new PhpImapAppender());
$c->set(MailCredentialsProvider::class, static fn(Container $c) => new PerUserMailCredentialsProvider(
$c->get(MailCredentialRepository::class),

View File

@ -9,6 +9,8 @@ use PDO;
final class AttachmentRepository
{
private const BLOB_READ_CHUNK_BYTES = 524288;
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
@ -69,7 +71,7 @@ final class AttachmentRepository
if ($name === '') {
return null;
}
$sql = 'SELECT id, name, file_name, mime_type, TO_BASE64(file_bytes) AS file_bytes_b64, created_at, updated_at
$sql = 'SELECT id, name, file_name, mime_type, OCTET_LENGTH(file_bytes) AS byte_size, created_at, updated_at
FROM mailshot_attachments
WHERE LOWER(name) = LOWER(:name)
LIMIT 1';
@ -79,8 +81,14 @@ final class AttachmentRepository
if ($row === false) {
return null;
}
$row['file_bytes'] = $this->decodeBase64Blob((string) ($row['file_bytes_b64'] ?? ''));
unset($row['file_bytes_b64']);
$expectedBytes = (int) ($row['byte_size'] ?? 0);
$row['file_bytes'] = $this->readFileBytes((int) ($row['id'] ?? 0), $expectedBytes);
if ($expectedBytes > 0 && strlen($row['file_bytes']) !== $expectedBytes) {
throw new \RuntimeException(
'Attachment "' . $name . '" was truncated while reading from the database: expected '
. $expectedBytes . ' bytes, got ' . strlen($row['file_bytes']) . ' bytes.'
);
}
$mimeType = trim((string) ($row['mime_type'] ?? ''));
if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename((string) ($row['file_name'] ?? ''));
@ -92,16 +100,32 @@ final class AttachmentRepository
return $row;
}
private function decodeBase64Blob(string $value): string
private function readFileBytes(int $id, int $expectedBytes): string
{
if ($value === '') {
if ($id <= 0 || $expectedBytes <= 0) {
return '';
}
$decoded = base64_decode($value, true);
if (!is_string($decoded)) {
throw new \RuntimeException('Failed to decode attachment blob.');
$bytes = '';
$offset = 0;
$stmt = $this->router->mailshotsPdo()->prepare('SELECT SUBSTRING(file_bytes, :start, :length) AS chunk FROM mailshot_attachments WHERE id = :id');
while ($offset < $expectedBytes) {
$length = min(self::BLOB_READ_CHUNK_BYTES, $expectedBytes - $offset);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->bindValue(':start', $offset + 1, PDO::PARAM_INT);
$stmt->bindValue(':length', $length, PDO::PARAM_INT);
$stmt->execute();
$chunk = $stmt->fetchColumn();
$stmt->closeCursor();
if (!is_string($chunk) || $chunk === '') {
break;
}
return $decoded;
$bytes .= $chunk;
$offset += strlen($chunk);
}
return $bytes;
}
private function updateMimeType(int $id, string $mimeType): void

View File

@ -19,10 +19,17 @@ final class MailshotQueryRepository
/** @return list<array<string, mixed>> */
public function all(): array
{
$sql = 'SELECT ID, name, dsl_text, updated_at FROM mailshot_queries ORDER BY name ASC';
$sql = 'SELECT ID, name, LEFT(COALESCE(dsl_text, \'\'), 1000) AS dsl_text, OCTET_LENGTH(dsl_text) AS dsl_byte_size, updated_at FROM mailshot_queries ORDER BY name ASC';
return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
/** @return list<string> */
public function names(): array
{
$rows = $this->router->mailshotsPdo()->query('SELECT name FROM mailshot_queries ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC);
return array_map(static fn(array $row): string => (string) ($row['name'] ?? ''), $rows);
}
public function find(int $id): ?array
{
$stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE ID = :id');

View File

@ -21,7 +21,7 @@ final class MailshotRepository
public function all(): array
{
$this->ensureRecipientEmailFieldColumn();
$sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots ORDER BY Purpose ASC';
$sql = "SELECT id, Purpose, DataSource, LEFT(COALESCE(Subject, ''), 1000) AS Subject FROM mailshots ORDER BY Purpose ASC";
return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}

View File

@ -9,6 +9,8 @@ use PDO;
final class PdfAssetRepository
{
private const BLOB_READ_CHUNK_BYTES = 524288;
private DatabaseRouter $router;
public function __construct(DatabaseRouter $router)
@ -30,7 +32,7 @@ final class PdfAssetRepository
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
$sql = 'SELECT id, name, file_name, mime_type, OCTET_LENGTH(file_bytes) AS byte_size, width_mm, height_mm, justification
FROM mailshot_pdf_assets
WHERE LOWER(name) = LOWER(:name)
LIMIT 1';
@ -40,11 +42,45 @@ final class PdfAssetRepository
if ($row === false) {
return null;
}
$row['file_bytes'] = $this->decodeBase64Blob((string) ($row['file_bytes_b64'] ?? ''));
unset($row['file_bytes_b64']);
$expectedBytes = (int) ($row['byte_size'] ?? 0);
$row['file_bytes'] = $this->readFileBytes((int) ($row['id'] ?? 0), $expectedBytes);
if ($expectedBytes > 0 && strlen($row['file_bytes']) !== $expectedBytes) {
throw new \RuntimeException(
'PDF asset "' . $name . '" was truncated while reading from the database: expected '
. $expectedBytes . ' bytes, got ' . strlen($row['file_bytes']) . ' bytes.'
);
}
return $row;
}
private function readFileBytes(int $id, int $expectedBytes): string
{
if ($id <= 0 || $expectedBytes <= 0) {
return '';
}
$bytes = '';
$offset = 0;
$stmt = $this->router->mailshotsPdo()->prepare('SELECT SUBSTRING(file_bytes, :start, :length) AS chunk FROM mailshot_pdf_assets WHERE id = :id');
while ($offset < $expectedBytes) {
$length = min(self::BLOB_READ_CHUNK_BYTES, $expectedBytes - $offset);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->bindValue(':start', $offset + 1, PDO::PARAM_INT);
$stmt->bindValue(':length', $length, PDO::PARAM_INT);
$stmt->execute();
$chunk = $stmt->fetchColumn();
$stmt->closeCursor();
if (!is_string($chunk) || $chunk === '') {
break;
}
$bytes .= $chunk;
$offset += strlen($chunk);
}
return $bytes;
}
/** @param array<string, mixed> $row */
public function create(array $row): int
{
@ -83,15 +119,4 @@ final class PdfAssetRepository
$stmt->execute(['id' => $id]);
}
private function decodeBase64Blob(string $value): string
{
if ($value === '') {
return '';
}
$decoded = base64_decode($value, true);
if (!is_string($decoded)) {
throw new \RuntimeException('Failed to decode PDF asset blob.');
}
return $decoded;
}
}

View File

@ -2,21 +2,37 @@
declare(strict_types=1);
// Load Composer dependencies (Twig, Dompdf, etc.) when available.
$autoloadCandidates = [
dirname(__DIR__) . '/vendor/autoload.php', // Preferred: packaged/deployed plugin-local vendor
dirname(__DIR__, 2) . '/vendor/autoload.php', // Backward-compatible: workspace/shared vendor
];
foreach ($autoloadCandidates as $composerAutoload) {
if (is_file($composerAutoload) && feca_mailshots_composer_autoload_is_usable($composerAutoload)) {
try {
require_once $composerAutoload;
} catch (\Throwable $e) {
error_log('FECA Mailshots skipped unusable Composer autoload: ' . $e->getMessage());
/** @return object|null Composer class loader when runtime dependencies are available. */
function feca_mailshots_load_composer_dependencies(): ?object
{
static $attempted = false;
static $loader = null;
if ($attempted) {
return is_object($loader) ? $loader : null;
}
$attempted = true;
$autoloadCandidates = [
dirname(__DIR__) . '/vendor/autoload.php',
dirname(__DIR__, 2) . '/vendor/autoload.php',
];
foreach ($autoloadCandidates as $composerAutoload) {
if (!is_file($composerAutoload) || !feca_mailshots_composer_autoload_is_usable($composerAutoload)) {
continue;
}
break;
try {
$candidate = require $composerAutoload;
if (is_object($candidate)) {
$loader = $candidate;
return $loader;
}
} catch (\Throwable $e) {
error_log('FECA Mailshots skipped unusable Composer autoload: ' . $e->getMessage());
}
}
return null;
}
function feca_mailshots_composer_autoload_is_usable(string $composerAutoload): bool
@ -55,3 +71,15 @@ spl_autoload_register(static function (string $class): void {
require_once $path;
}
});
// Composer's generated function files are expensive to load. Load them only
// when Twig or Dompdf is first requested, then delegate that initial class load.
spl_autoload_register(static function (string $class): void {
if (strncmp($class, 'Twig\\', 5) !== 0 && strncmp($class, 'Dompdf\\', 7) !== 0) {
return;
}
$loader = feca_mailshots_load_composer_dependencies();
if ($loader !== null && method_exists($loader, 'loadClass')) {
$loader->loadClass($class);
}
});

View File

@ -3,25 +3,28 @@
declare(strict_types=1);
use FecaMailshots\Plugin;
use FecaMailshots\Infrastructure\MailshotSchemaInstaller;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\WordPress\ProductionWordPressFacade;
require_once __DIR__ . '/autoload.php';
$setupErrors = [];
$getOption = static function (string $key) use (&$setupErrors): string {
if (!function_exists('get_option')) {
if (!function_exists('get_option')) {
throw new \RuntimeException('WordPress get_option() is unavailable while bootstrapping mailshots plugin.');
}
$raw = get_option(\FecaMailshots\Admin\SetupAdminPage::OPTION_KEY, []);
if (!is_array($raw)) {
}
$setupErrors = [];
$rawSetup = get_option(\FecaMailshots\Admin\SetupAdminPage::OPTION_KEY, []);
if (!is_array($rawSetup)) {
$setupErrors[] = 'Mailshots setup option is missing or invalid.';
return '';
}
if (!array_key_exists($key, $raw)) {
$rawSetup = [];
}
$readSetupValue = static function (string $key) use (&$setupErrors, $rawSetup): string {
if (!array_key_exists($key, $rawSetup)) {
$setupErrors[] = 'Missing setup configuration key: ' . $key;
return '';
}
$value = trim((string) $raw[$key]);
$value = trim((string) $rawSetup[$key]);
if ($value === '') {
$setupErrors[] = 'Empty setup configuration value: ' . $key;
}
@ -29,16 +32,37 @@ $getOption = static function (string $key) use (&$setupErrors): string {
};
$dbConfig = [
'MYSQL_HOST' => $getOption('db_host'),
'MYSQL_PORT' => $getOption('db_port'),
'MYSQL_USER' => $getOption('db_user'),
'MYSQL_PASSWORD' => $getOption('db_password'),
'MAILSHOTS_REMOTE_MYSQL_DB' => $getOption('mailshots_db_name'),
'MEMBERS_REMOTE_MYSQL_DB' => $getOption('members_db_name'),
'FEN_REMOTE_MYSQL_DB' => $getOption('fen_db_name'),
'MYSQL_HOST' => $readSetupValue('db_host'),
'MYSQL_PORT' => $readSetupValue('db_port'),
'MYSQL_USER' => $readSetupValue('db_user'),
'MYSQL_PASSWORD' => $readSetupValue('db_password'),
'MAILSHOTS_REMOTE_MYSQL_DB' => $readSetupValue('mailshots_db_name'),
'MEMBERS_REMOTE_MYSQL_DB' => $readSetupValue('members_db_name'),
'FEN_REMOTE_MYSQL_DB' => $readSetupValue('fen_db_name'),
];
$wp = new ProductionWordPressFacade();
if ($setupErrors === [] && function_exists('is_admin') && is_admin()) {
$target = MailshotSchemaInstaller::migrationTarget($dbConfig);
$state = get_option(MailshotSchemaInstaller::STATE_OPTION_KEY, []);
$sameTarget = is_array($state)
&& ($state['version'] ?? '') === $target['version']
&& ($state['config_hash'] ?? '') === $target['config_hash'];
if ($sameTarget && ($state['status'] ?? '') === 'failed') {
$setupErrors[] = 'Mailshots database schema setup failed: ' . (string) ($state['error'] ?? 'unknown error');
} elseif (!$sameTarget || ($state['status'] ?? '') !== 'complete') {
try {
(new MailshotSchemaInstaller(new PdoDatabaseRouter($dbConfig)))->install();
update_option(MailshotSchemaInstaller::STATE_OPTION_KEY, $target + ['status' => 'complete']);
} catch (\Throwable $e) {
update_option(MailshotSchemaInstaller::STATE_OPTION_KEY, $target + [
'status' => 'failed',
'error' => $e->getMessage(),
]);
$setupErrors[] = 'Mailshots database schema setup failed: ' . $e->getMessage();
}
}
}
if ($setupErrors !== []) {
$setupPage = new \FecaMailshots\Admin\SetupAdminPage($wp, array_values(array_unique($setupErrors)), true);
$setupPage->register();

View File

@ -84,6 +84,10 @@ Hard constraints for this task:
## Dependency Installation Scripts
Runtime Composer dependencies must be autoloaded lazily when Twig or Dompdf is first used. Ordinary WordPress/admin bootstrap requests must not load Composer-generated runtime function files.
Mailshot database schema installation/checking must run only when the schema version or database configuration changes. Normal page reads must not execute `CREATE TABLE`, `ALTER TABLE`, or `information_schema` migration checks.
- `scripts/install_dependencies.sh`
- Installs Composer dependencies (`vendor/`) for plugin runtime.
- Installs/bundles editor assets (Jodit + Ace) into `feca_mailshots_plugin/assets/vendor/`.

View File

@ -95,6 +95,8 @@ filter_name = "selected-renewal"
| "invoice-ids"
| "fen1-contact"
| "primary-contact"
| "member-account"
| "affiliate-account"
| "member-or-affiliate-or-parish-council" ;
field_ref = source_ref , "." , field_name ;
@ -134,11 +136,14 @@ digit = "0"…"9" ;
* For custom sources, an explicit field equality predicate can provide join semantics.
* `where` applies after source composition.
* `not` negates only the next predicate/group.
* Predefined predicates are `selected-renewal`, `pending-renewal`, `selected`, `issue`, `pending-invoice`, `selected-invoice`, `invoice-ids`, `fen1-contact`, `primary-contact`, `member-or-affiliate-or-parish-council`, and `account-has-article-in-issue`.
* Comparison operators (`=`, `!=`, `contains`, `starts-with`, and `ends-with`) treat a database `NULL` field value as an empty string. This makes a negated comparison the complete logical inverse of its positive form; for example, `not (source.field contains 'text')` includes rows where `source.field` is `NULL`.
* Predefined predicates are `selected-renewal`, `pending-renewal`, `selected`, `issue`, `pending-invoice`, `selected-invoice`, `invoice-ids`, `fen1-contact`, `primary-contact`, `member-account`, `affiliate-account`, `member-or-affiliate-or-parish-council`, and `account-has-article-in-issue`.
* `renewals` is a built-in source mapped to membership renewal rows.
* `pending-renewal` applies only when source set includes `renewals` and means `renewals.status = 'pending'`.
* `selected-renewal` applies only when source set includes `renewals` and means `renewals.selected = true`.
* `primary-contact` applies only when source set includes `contacts` and means `contacts.is_contact_1` is truthy.
* `member-account` applies only when source set includes `accounts` and means the account type picklist slug is `member`.
* `affiliate-account` applies only when source set includes `accounts` and means the account type picklist slug is `affiliate`.
* FEN editorial, advertising, and invoice data must be exposed as built-in sources, not custom table references, using the source names below.
* These built-in sources are derived from the sibling `../feca2-app/server/src/lib/mailshotDsl.js` implementation, except that `articles` must now be promoted to a first-class built-in source. In the sibling implementation, `Articles` is used by an issue-scoped article filter but is not listed as a standalone built-in source.
* FEN built-in source table mappings:
@ -148,6 +153,7 @@ digit = "0"…"9" ;
* `articles` maps to `fen.Articles`.
* `issues` maps to `fen.Issues`.
* `invoices` maps to `fen.invoices`.
* `ad_sizes` maps to `${FEN_REMOTE_MYSQL_DB}.Ad_Sizes`. The configured database/schema name is an implementation detail and must not appear in the source name shown by the builder or in generated DSL.
* Required FEN source fields and canonical token aliases:
* `advertisers`: `name` / `advertisername` from `AdvertiserName`.
* `ads`: `id` from `ID`, `advertiser` from `Advertiser`, `adsize` / `size` from `AdSize`, `price` from `Price`, `issue` from the related page `Issue`, `pageid` from `PageID`, `state` from `State`, `notes` from `Notes`.
@ -162,6 +168,7 @@ digit = "0"…"9" ;
* `fen.ads`: `ID`, `PageID`, `AdSize`, `Advertiser`, `Price`, `State`, `Notes`.
* `fen.advertisers`: `Entry ID`, `AdvertiserName`, `title`, `contact_name`, `address_1`, `address_2`, `town`, `post_code`, `Description`, `IsLapsed?`, `Home Phone`, `Phone`, `Email`, `Selected`.
* `fen.invoices`: `id`, `issue_id`, `ad_id`, `invoice_number`, `invoice_date`, `due_date`, `invoice_page`, `invoice_size`, `invoice_price`, `status`, `payment_date`, `amount_paid`, `payment_method`, `payment_reference`, `notes`, `created_at`, `updated_at`.
* `fen.Ad_Sizes`: `SizeName`; all readable table fields are exposed using their physical names.
* `selected` applies only when source set includes `advertisers` and means `advertisers.Selected` is truthy.
* `issue(<issue>)` accepts exactly one numeric issue ID. It applies when the source set includes one of `advertisers`, `ads`, `pages`, `articles`, `issues`, or `invoices`, and means the row is associated with that issue. The compiler may traverse hidden approved paths to apply the filter, but only explicitly cited sources contribute fields to the result/template context.
* When `issues` is explicitly appended to an otherwise joined source set and a non-negated `issue(<issue>)` filter is present, `issues` may be attached as a one-row issue context source even when there is no direct approved join path from the preceding source. Without that constraining issue filter, the join must remain invalid.
@ -207,6 +214,8 @@ The compiler must use an explicit join graph per source pair. Example v1 join pa
* `advertisers` -> `ads`: normalized `advertisers.AdvertiserName = ads.Advertiser`
* `invoices` -> `ads`: `invoices.ad_id = ads.ID`
* `ads` -> `invoices`: `ads.ID = invoices.ad_id`
* `ads` -> `ad_sizes`: `ads.AdSize = ad_sizes.SizeName`
* `ad_sizes` -> `ads`: `ad_sizes.SizeName = ads.AdSize`
* `invoices` -> `issues`: `invoices.issue_id = issues.ID`
* `issues` -> `invoices`: `issues.ID = invoices.issue_id`
* `invoices` -> `pages`: `invoices.issue_id = pages.Issue`
@ -230,6 +239,8 @@ No implicit join behavior is allowed:
* `contacts where contacts.Last contains 'smith'`
* `contacts and accounts where accounts.Type = 'Member' and contacts.FENContact1 = true`
* `accounts where member-or-affiliate-or-parish-council`
* `accounts where member-account`
* `accounts where affiliate-account`
* `renewals where pending-renewal`
* `renewals where selected-renewal`
* `renewals and accounts and contacts where pending-renewal`
@ -253,6 +264,8 @@ No implicit join behavior is allowed:
* `accounts where pending-renewal` (invalid: filter requires `renewals` source)
* `accounts where fen1-contact` (invalid: filter requires `contacts` source)
* `accounts where primary-contact` (invalid: filter requires `contacts` source)
* `contacts where member-account` (invalid: filter requires `accounts` source)
* `contacts where affiliate-account` (invalid: filter requires `accounts` source)
* `ads where selected` (invalid: filter requires `advertisers` source)
* `advertisers where pending-invoice` (invalid: filter requires `invoices` source)
* `pages where issue` (invalid: `issue` requires exactly one numeric issue ID)
@ -295,6 +308,8 @@ Optional:
Provide a UI to view, create, update, validate, and preview DSL sentences.
The initial Data Sources page GET must load only the saved query list and built-in source names. Schema enumeration and source-column discovery must be requested lazily within the Build DSL dialog when the user opens or uses the relevant controls; failures must be displayed inside that dialog.
### Mailshot data source page
Add a dedicated page "Data Sources" under FECA Mailshots admin page for managing data source sentences.

View File

@ -26,6 +26,6 @@
* [X] Mailshot data sources. Add "Duplicate" button per row.
* [X] Select a single row in "review recipients". "Run Mailshot to selected rows" says "Run mailshot to 1 selected rows?". Yes -> error "None of the selected recipient rows exist in the current query result."
* [X] Error report from wordpress. - packaging was missing a file
* [ ] Subject to "Memory box café generates an error from receiveing yahoo email "subject contains an invalid character". Need to check rules for character set in subject and modify special characters accordingly. Problem characters include quote (All Saint's) and e accent (café).
* [ ] (patch 32, 33) Test email including these characters critical errors wordpress. Perhaps because "Last Run Rows" was non-empty and related to a previous different mailshot / data source. Still critical errors after attempt to make more robust.
* [ ] patch 30 also criticals sending to "test" mailshot, but OK sending to fen contacts.
* [X] Subject to "Memory box café generates an error from receiveing yahoo email "subject contains an invalid character". Need to check rules for character set in subject and modify special characters accordingly. Problem characters include quote (All Saint's) and e accent (café).
* [X] (patch 32, 33) Test email including these characters critical errors wordpress. Perhaps because "Last Run Rows" was non-empty and related to a previous different mailshot / data source. Still critical errors after attempt to make more robust.
* [X] patch 30 also criticals sending to "test" mailshot, but OK sending to fen contacts.

View File

@ -1,6 +1,43 @@
import { test, expect } from '@playwright/test';
import { adminPath, apiPost, cleanupByNames, ensureDataSource } from './helpers.mjs';
test('data sources: builder metadata is lazy and browser runtime is clean', async ({ page }) => {
const runtimeErrors = [];
const metadataRequests = [];
page.on('pageerror', error => runtimeErrors.push(error.message));
page.on('console', message => {
if (message.type() === 'error') {
runtimeErrors.push(message.text());
}
});
page.on('request', request => {
const url = request.url();
if (url.includes('op=schemas') || url.includes('op=source_fields')) {
metadataRequests.push(url);
}
});
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
expect(metadataRequests).toHaveLength(0);
await page.getByRole('button', { name: 'New Data Source' }).click();
await page.locator('#ds-build-dsl-open').click();
await expect(page.locator('#ds-builder-modal')).toBeVisible();
await expect.poll(() => metadataRequests.filter(url => url.includes('op=schemas')).length).toBe(1);
await expect(page.locator('#ds-builder-schema option')).not.toHaveCount(1);
await page.locator('.ds-source-built[value="contacts"]').check();
await page.locator('#ds-builder-add-row').click();
const row = page.locator('.feca-builder-row').first();
await row.locator('select').first().selectOption('compare');
await row.locator('select').nth(1).selectOption('contacts');
await expect.poll(() => metadataRequests.filter(url => url.includes('op=source_fields')).length).toBe(1);
await expect(row.locator('select').nth(2).locator('option')).not.toHaveCount(1);
expect(runtimeErrors).toEqual([]);
});
test('data sources: create + preview + validation failure path', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_${uniq}`;
@ -147,3 +184,38 @@ test('data sources: DSL builder round-trip preserves FEN built-in filters', asyn
}
}
});
test('data sources: DSL builder round-trip preserves account type filters', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_account_type_roundtrip_${uniq}`;
const dsl = 'accounts where member-account and not (affiliate-account)';
try {
await ensureDataSource(request, dsName, dsl);
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
const row = page.locator('tr', {
has: page.getByRole('cell', { name: dsName })
}).first();
await expect(row).toBeVisible();
await row.getByRole('link', { name: 'Edit' }).click();
await expect(page.locator('#ds-editor-modal')).toBeVisible();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
await page.locator('#ds-build-dsl-open').click();
await expect(page.locator('#ds-builder-modal')).toBeVisible();
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
await page.locator('#ds-builder-apply').click();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName] });
} catch {
// best effort
}
}
});

View File

@ -40,7 +40,7 @@ test.describe('new selected-rows and all-recipients features', () => {
await dialog.dismiss();
});
await page.getByRole('button', { name: 'Send Test Email' }).click();
await page.getByRole('button', { name: 'Send Test for all to Test Email Address' }).click();
await expect.poll(() => dialogText).toContain(`Send ${recipientCount} emails to the entered address?`);
} finally {
try {

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\AttachmentService;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\AttachmentRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '127.0.0.1',
'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'),
'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'),
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
];
$router = new PdoDatabaseRouter($dbConfig);
$repo = new AttachmentRepository($router);
$service = new AttachmentService($repo);
$name = 'blob_roundtrip_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3));
$id = null;
$payload = "%PDF-1.7\n" . random_bytes(1536 * 1024) . "\n%%EOF";
try {
$saved = $service->save(null, [
'name' => $name,
'file_name' => 'roundtrip.pdf',
'mime_type' => 'application/pdf',
'file_bytes_base64' => base64_encode($payload),
]);
if (($saved['ok'] ?? false) !== true) {
fwrite(STDERR, 'Failed to save large attachment: ' . json_encode($saved) . "\n");
exit(1);
}
$id = (int) ($saved['id'] ?? 0);
$reloaded = $repo->findByName($name);
if (!is_array($reloaded)) {
fwrite(STDERR, "Large attachment was not found after save\n");
exit(1);
}
$bytes = (string) ($reloaded['file_bytes'] ?? '');
if (strlen($bytes) !== strlen($payload)) {
fwrite(STDERR, 'Large attachment byte length changed: expected ' . strlen($payload) . ', got ' . strlen($bytes) . "\n");
exit(1);
}
if (hash('sha256', $bytes) !== hash('sha256', $payload)) {
fwrite(STDERR, "Large attachment bytes changed after DB round-trip\n");
exit(1);
}
} finally {
if ($id !== null && $id > 0) {
try {
$repo->delete($id);
} catch (Throwable $e) {
}
}
}
echo "Attachment blob round-trip regression test passed\n";

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotQueryRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '127.0.0.1',
'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'),
'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'),
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
];
$repo = new MailshotQueryRepository(new PdoDatabaseRouter($dbConfig));
$id = null;
$name = 'ds_list_lightweight_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3));
$dsl = 'contacts where contacts.id = 1 ' . str_repeat('x', 1800000);
try {
$id = $repo->create(['name' => $name, 'dsl_text' => $dsl]);
if ($id <= 0) {
fwrite(STDERR, "Data source create did not return an id\n");
exit(1);
}
$listedRow = null;
foreach ($repo->all() as $row) {
if ((int) ($row['ID'] ?? 0) === $id) {
$listedRow = $row;
break;
}
}
if (!is_array($listedRow)) {
fwrite(STDERR, "Data source was not returned by list query\n");
exit(1);
}
if (strlen((string) ($listedRow['dsl_text'] ?? '')) > 1000) {
fwrite(STDERR, "Data source list query should return only a bounded DSL preview\n");
exit(1);
}
$fullRow = $repo->find($id);
if (!is_array($fullRow) || (string) ($fullRow['dsl_text'] ?? '') !== $dsl) {
fwrite(STDERR, "Data source find query should fetch full DSL for editing/execution\n");
exit(1);
}
} finally {
if ($id !== null && $id > 0) {
try {
$repo->delete($id);
} catch (Throwable $e) {
}
}
}
echo "Data source list lightweight regression test passed\n";

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '127.0.0.1',
'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'),
'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'),
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
];
$repo = new MailshotRepository(new PdoDatabaseRouter($dbConfig));
$id = null;
$purpose = 'list_lightweight_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3));
$subject = str_repeat('S', 1800000);
$message = str_repeat('M', 1800000);
$pdfAttachment = str_repeat('P', 1800000);
try {
$id = $repo->create([
'Purpose' => $purpose,
'DataSource' => 'test_source',
'CC' => '',
'BCC' => '',
'Subject' => $subject,
'Message' => $message,
'PDFAttachment' => $pdfAttachment,
'AttachmentNames' => '[]',
'PDFFilenameDerivedFrom' => '',
'ReplyTo' => '',
'RecipientEmailField' => '',
]);
if ($id <= 0) {
fwrite(STDERR, "Mailshot create did not return an id\n");
exit(1);
}
$listedRow = null;
foreach ($repo->all() as $row) {
if ((int) ($row['id'] ?? 0) === $id) {
$listedRow = $row;
break;
}
}
if (!is_array($listedRow)) {
fwrite(STDERR, "Mailshot was not returned by list query\n");
exit(1);
}
if (array_key_exists('Message', $listedRow) || array_key_exists('PDFAttachment', $listedRow) || array_key_exists('CC', $listedRow) || array_key_exists('BCC', $listedRow)) {
fwrite(STDERR, "Mailshot list query should not fetch large template fields\n");
exit(1);
}
if (strlen((string) ($listedRow['Subject'] ?? '')) > 1000) {
fwrite(STDERR, "Mailshot list query should only fetch a bounded subject preview\n");
exit(1);
}
$fullRow = $repo->find($id);
if (!is_array($fullRow) || (string) ($fullRow['Subject'] ?? '') !== $subject || (string) ($fullRow['Message'] ?? '') !== $message || (string) ($fullRow['PDFAttachment'] ?? '') !== $pdfAttachment) {
fwrite(STDERR, "Mailshot find query should fetch full template fields for editing/sending\n");
exit(1);
}
} finally {
if ($id !== null && $id > 0) {
try {
$repo->delete($id);
} catch (Throwable $e) {
}
}
}
echo "Mailshot list lightweight regression test passed\n";

View File

@ -115,6 +115,8 @@ $queryId = null;
$mailshotId = null;
$attachmentId = null;
$attachmentName = 'phase4_att_' . $uniq;
$generatedPdfPaths = [];
$generatedPdfDirs = [];
try {
$savedQ = $dataSourceService->save(null, $queryName, $dsl);
@ -160,8 +162,63 @@ try {
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");
$mergedPdfPath = (string) ($pdfBatch['merged_pdf_path'] ?? '');
if ($mergedPdfPath === '' || !is_file($mergedPdfPath)) {
fwrite(STDERR, "generatePdfBatch should return a merged PDF temp file path\n");
exit(1);
}
$generatedPdfPaths[] = $mergedPdfPath;
if (file_get_contents($mergedPdfPath, false, null, 0, 4) !== '%PDF') {
fwrite(STDERR, "generatePdfBatch merged_pdf_path should point to a PDF payload\n");
exit(1);
}
if (array_key_exists('merged_pdf_bytes', $pdfBatch)) {
fwrite(STDERR, "generatePdfBatch should not return merged PDF bytes\n");
exit(1);
}
$pdfFiles = is_array($pdfBatch['files'] ?? null) ? $pdfBatch['files'] : [];
if ($pdfFiles === []) {
fwrite(STDERR, "generatePdfBatch should return individual PDF file rows\n");
exit(1);
}
foreach ($pdfFiles as $file) {
$path = (string) ($file['path'] ?? '');
if ($path === '' || !is_file($path)) {
fwrite(STDERR, "generatePdfBatch individual PDF path missing\n");
exit(1);
}
$generatedPdfPaths[] = $path;
if (array_key_exists('content_bytes', $file)) {
fwrite(STDERR, "generatePdfBatch file rows should not return PDF bytes\n");
exit(1);
}
if (file_get_contents($path, false, null, 0, 4) !== '%PDF') {
fwrite(STDERR, "generatePdfBatch individual file should be a PDF payload\n");
exit(1);
}
}
$filesDir = (string) ($pdfBatch['files_dir'] ?? '');
if ($filesDir !== '') {
$generatedPdfDirs[] = $filesDir;
}
$mergedOnly = $run->generatePdfBatch($mailshotId, true, false);
if (($mergedOnly['ok'] ?? false) !== true) {
fwrite(STDERR, 'generatePdfBatch merged-only failed: ' . json_encode($mergedOnly) . "\n");
exit(1);
}
$mergedOnlyPath = (string) ($mergedOnly['merged_pdf_path'] ?? '');
if ($mergedOnlyPath === '' || !is_file($mergedOnlyPath)) {
fwrite(STDERR, "generatePdfBatch merged-only should return a temp file path\n");
exit(1);
}
$generatedPdfPaths[] = $mergedOnlyPath;
if (($mergedOnly['files'] ?? []) !== []) {
fwrite(STDERR, "generatePdfBatch merged-only should not return individual files\n");
exit(1);
}
if (array_key_exists('merged_pdf_bytes', $mergedOnly)) {
fwrite(STDERR, "generatePdfBatch merged-only should not return merged PDF bytes\n");
exit(1);
}
@ -204,6 +261,49 @@ try {
fwrite(STDERR, "sendTest PDF attachment content is not a PDF payload\n");
exit(1);
}
if (!is_array($sendTest['message_size'] ?? null)) {
fwrite(STDERR, "sendTest should return message size diagnostics\n");
exit(1);
}
$oldSizeLimit = getenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES');
try {
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES=1024');
$sendTooLarge = $run->sendTest($mailshotId, 0, 'receiver@example.org');
if (($sendTooLarge['ok'] ?? true) !== false) {
fwrite(STDERR, "sendTest should fail when estimated MIME size exceeds configured limit\n");
exit(1);
}
$tooLargeText = implode('; ', (array) ($sendTooLarge['errors'] ?? []));
if (strpos($tooLargeText, 'Estimated message size') === false) {
fwrite(STDERR, "sendTest size-limit error text mismatch: {$tooLargeText}\n");
exit(1);
}
if (!is_array($sendTooLarge['message_size'] ?? null)) {
fwrite(STDERR, "sendTest size-limit failure should include diagnostics\n");
exit(1);
}
$sendAllTooLarge = $run->sendTestAll($mailshotId, 'receiver@example.org');
if (($sendAllTooLarge['ok'] ?? true) !== false) {
fwrite(STDERR, "sendTestAll should fail when estimated MIME size exceeds configured limit\n");
exit(1);
}
if ((int) ($sendAllTooLarge['failed'] ?? 0) <= 0) {
fwrite(STDERR, "sendTestAll size-limit failure should count failed recipients\n");
exit(1);
}
$sendAllTooLargeText = implode('; ', (array) ($sendAllTooLarge['errors'] ?? []));
if (strpos($sendAllTooLargeText, 'Estimated message size') === false) {
fwrite(STDERR, "sendTestAll size-limit error text mismatch: {$sendAllTooLargeText}\n");
exit(1);
}
} finally {
if ($oldSizeLimit === false) {
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES');
} else {
putenv('FECA_MAILSHOTS_MAX_ESTIMATED_MIME_BYTES=' . $oldSizeLimit);
}
}
// Regression: configured-but-missing attachment must return a controlled error, not throw/fatal.
$mailshotRepo->update($mailshotId, [
@ -257,6 +357,16 @@ try {
echo "smtp_calls={$smtp->count}\n";
echo "imap_calls={$imap->count}\n";
} finally {
foreach ($generatedPdfPaths as $path) {
if (is_string($path) && $path !== '') {
@unlink($path);
}
}
foreach ($generatedPdfDirs as $dir) {
if (is_string($dir) && $dir !== '') {
@rmdir($dir);
}
}
if ($mailshotId !== null) {
try { $lastRunRepo->clearForMailshot($mailshotId); } catch (Throwable $e) {}
try { $mailshotRepo->delete($mailshotId); } catch (Throwable $e) {}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '127.0.0.1',
'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'),
'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'),
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
];
$router = new PdoDatabaseRouter($dbConfig);
$queries = new MailshotQueryRepository($router);
$mailshots = new MailshotRepository($router);
$metadata = new DatabaseSourceMetadataProvider($router);
$service = new DataSourceService(
$queries,
$router,
new DslParser(),
new DslValidator($metadata),
new DslCompiler($metadata),
$metadata,
$mailshots
);
$first = $service->review('contacts', 3, 0);
$second = $service->review('contacts', 3, 3);
foreach ([$first, $second] as $result) {
if (($result['errors'] ?? []) !== []) {
fwrite(STDERR, 'Review recipients paging returned errors: ' . implode('; ', (array) $result['errors']) . "\n");
exit(1);
}
if ((int) ($result['returned_count'] ?? 0) > 3 || count((array) ($result['rows'] ?? [])) > 3) {
fwrite(STDERR, "Review recipients paging exceeded the requested limit\n");
exit(1);
}
}
if ((int) ($first['count'] ?? 0) !== (int) ($second['count'] ?? -1)) {
fwrite(STDERR, "Review recipients paging should preserve the total count across pages\n");
exit(1);
}
if ((int) ($second['offset'] ?? -1) !== 3) {
fwrite(STDERR, "Review recipients second page should report the requested offset\n");
exit(1);
}
echo "Review recipients paging regression test passed\n";

View File

@ -10,11 +10,17 @@ final class FakeMetadataProvider implements SourceMetadataProvider
{
/** @var array<string, list<string>> */
private array $fields;
/** @var list<string> */
private array $builtInSources;
/** @param array<string, list<string>> $fields */
public function __construct(array $fields)
/** @param array<string, list<string>> $fields @param list<string> $additionalBuiltInSources */
public function __construct(array $fields, array $additionalBuiltInSources = [])
{
$this->fields = $fields;
$this->builtInSources = array_values(array_unique(array_merge(
array_values(array_filter(array_keys($fields), static fn(string $source): bool => !str_contains($source, '.'))),
$additionalBuiltInSources
)));
}
public function sourceExists(string $source): bool
@ -22,6 +28,11 @@ final class FakeMetadataProvider implements SourceMetadataProvider
return isset($this->fields[$source]);
}
public function isBuiltInSource(string $source): bool
{
return in_array($source, $this->builtInSources, true);
}
public function sourceFields(string $source): array
{
return $this->fields[$source] ?? [];
@ -56,6 +67,10 @@ final class FakeMetadataProvider implements SourceMetadataProvider
'invoices|pages' => ['left' => 'invoices.issue_id', 'right' => 'pages.Issue'],
'pages|invoices' => ['left' => 'pages.Issue', 'right' => 'invoices.issue_id'],
];
if (isset($this->fields['ad_sizes'])) {
$pairs['ads|ad_sizes'] = ['left' => 'ads.AdSize', 'right' => 'ad_sizes.SizeName'];
$pairs['ad_sizes|ads'] = ['left' => 'ad_sizes.SizeName', 'right' => 'ads.AdSize'];
}
return $pairs[$left . '|' . $right] ?? null;
}

View File

@ -1,75 +0,0 @@
<?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'],
[],
[],
"All Saint's café",
'<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);
}
if (strpos($plain, "Subject: All Saint's café") !== false) {
fwrite(STDERR, "Subject header should not contain raw UTF-8/punctuation text\n");
exit(1);
}
if (strpos($plain, 'Subject: =?UTF-8?B?QWxsIFNhaW50J3MgY2Fmw6k=?=') === false) {
fwrite(STDERR, "Subject header missing expected RFC 2047 encoding\n");
exit(1);
}
if (strpos($plain, 'From: =?UTF-8?B?RnJvbSBOYW1l?= <from@example.org>') === false) {
fwrite(STDERR, "From display name missing expected RFC 2047 encoding\n");
exit(1);
}
$bytes = random_bytes(256 * 1024);
$mime = $buildMime->invoke(
$sender,
'from@example.org',
'From Name',
['to@example.org'],
[],
[],
'Subject',
'<p>Hello</p>',
null,
[[
'filename' => 'sample.jpg',
'mime_type' => 'image/jpeg',
'content_bytes' => $bytes,
]]
);
if (!is_string($mime) || strpos($mime, 'multipart/mixed') === false) {
fwrite(STDERR, "Multipart MIME expected for attachment payload\n");
exit(1);
}
if (strpos($mime, 'Content-Type: image/jpeg; name="sample.jpg"') === false) {
fwrite(STDERR, "Attachment MIME part missing expected content-type/filename\n");
exit(1);
}
if (strpos($mime, base64_encode(substr($bytes, 0, 24))) === false) {
fwrite(STDERR, "Attachment bytes do not appear to be base64-encoded into MIME payload\n");
exit(1);
}
echo "BasicSmtpSender MIME regression test passed\n";

View File

@ -7,13 +7,11 @@ if (!defined('ABSPATH')) {
}
$GLOBALS['feca_bootstrap_actions'] = [];
$GLOBALS['feca_setup_option_reads'] = 0;
function get_option($key, $default = null)
{
if ($key !== \FecaMailshots\Admin\SetupAdminPage::OPTION_KEY) {
return $default;
}
return [
$config = [
'db_host' => 'invalid-host.local.test',
'db_port' => '3306',
'db_user' => 'mailshots',
@ -22,6 +20,28 @@ function get_option($key, $default = null)
'members_db_name' => 'members',
'fen_db_name' => 'fen',
];
if ($key === \FecaMailshots\Admin\SetupAdminPage::OPTION_KEY) {
$GLOBALS['feca_setup_option_reads']++;
return $config;
}
if ($key === \FecaMailshots\Infrastructure\MailshotSchemaInstaller::STATE_OPTION_KEY) {
$dbConfig = [
'MYSQL_HOST' => $config['db_host'],
'MYSQL_PORT' => $config['db_port'],
'MYSQL_USER' => $config['db_user'],
'MYSQL_PASSWORD' => $config['db_password'],
'MAILSHOTS_REMOTE_MYSQL_DB' => $config['mailshots_db_name'],
'MEMBERS_REMOTE_MYSQL_DB' => $config['members_db_name'],
'FEN_REMOTE_MYSQL_DB' => $config['fen_db_name'],
];
return \FecaMailshots\Infrastructure\MailshotSchemaInstaller::migrationTarget($dbConfig) + ['status' => 'complete'];
}
return $default;
}
function is_admin(): bool
{
return true;
}
function add_action(string $hook, callable $callback): void
@ -47,5 +67,15 @@ if (!isset($actions['admin_post_feca_mailshots_data_sources_api'], $actions['adm
fwrite(STDERR, "Expected normal handlers to register without touching the database\n");
exit(1);
}
if ($GLOBALS['feca_setup_option_reads'] !== 1) {
fwrite(STDERR, "Expected setup configuration to be read exactly once during bootstrap\n");
exit(1);
}
foreach (get_included_files() as $includedFile) {
if (strpos($includedFile, '/vendor/thecodingmachine/safe/') !== false) {
fwrite(STDERR, "Composer runtime function files should remain unloaded during admin bootstrap\n");
exit(1);
}
}
echo "Bootstrap full setup no DB touch regression test passed\n";

View File

@ -6,6 +6,7 @@ require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
require_once __DIR__ . '/FakeMetadataProvider.php';
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
@ -16,6 +17,7 @@ $metadata = new FakeMetadataProvider([
$parser = new DslParser();
$compiler = new DslCompiler($metadata);
$validator = new DslValidator($metadata);
$ast = $parser->parse("accounts and contacts where accounts.account_type_id = 1");
$compiled = $compiler->compile($ast);
@ -29,8 +31,39 @@ if (strpos($sql, 's_accounts.`account_type_id` AS `accounts.account_type_id`') =
fwrite(STDERR, "Expected physical accounts.account_type_id projection\n");
exit(1);
}
if (strpos($sql, 's_accounts.`account_type_id` = ?') === false) {
if (strpos($sql, "COALESCE(s_accounts.`account_type_id`, '') = ?") === false) {
fwrite(STDERR, "Expected predicate accounts.account_type_id to compile against physical column\n");
exit(1);
}
$memberAst = $parser->parse('accounts where member-account');
$memberValidation = $validator->validate($memberAst);
if (($memberValidation['errors'] ?? []) !== []) {
fwrite(STDERR, "Expected member-account to validate on accounts: " . json_encode($memberValidation['errors']) . "\n");
exit(1);
}
$memberSql = (string) ($compiler->compile($memberAst)['sql'] ?? '');
foreach ([
's_accounts.`account_type_id` IN (SELECT id FROM `picklist_account_type`',
"LOWER(TRIM(COALESCE(`slug`, ''))) = 'member'",
] as $needle) {
if (strpos($memberSql, $needle) === false) {
fwrite(STDERR, "Expected member-account SQL to contain {$needle}\n{$memberSql}\n");
exit(1);
}
}
$affiliateAst = $parser->parse('accounts where affiliate-account');
$affiliateSql = (string) ($compiler->compile($affiliateAst)['sql'] ?? '');
if (strpos($affiliateSql, "LOWER(TRIM(COALESCE(`slug`, ''))) = 'affiliate'") === false) {
fwrite(STDERR, "Expected affiliate-account SQL to filter by affiliate slug\n{$affiliateSql}\n");
exit(1);
}
$invalidValidation = $validator->validate($parser->parse('contacts where member-account'));
if (!in_array('Filter member-account requires source accounts', $invalidValidation['errors'] ?? [], true)) {
fwrite(STDERR, "Expected member-account to require accounts source: " . json_encode($invalidValidation['errors'] ?? []) . "\n");
exit(1);
}
echo "DSL accounts physical-schema regression test passed\n";

View File

@ -10,6 +10,7 @@ use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
$adSizesSource = 'ad_sizes';
$metadata = new FakeMetadataProvider([
'advertisers' => ['AdvertiserName', 'Selected', 'IsLapsed?'],
'ads' => ['ID', 'Advertiser', 'AdSize', 'Price', 'PageID', 'State', 'Notes'],
@ -17,6 +18,7 @@ $metadata = new FakeMetadataProvider([
'articles' => ['ID', 'PageID', 'ArticleNumber', 'Content', 'MemberName', 'Author', 'DCN', 'ArticleWords', 'OtherWords', 'OtherContent'],
'issues' => ['ID', 'IssueMonths', 'Description'],
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'status'],
$adSizesSource => ['SizeName', 'Width', 'Height'],
'contacts' => ['id', 'account_id', 'last_name', 'contact_email_1', 'is_fen_1', 'is_deleted'],
'accounts' => ['id', 'name', 'is_deleted'],
]);
@ -90,6 +92,37 @@ foreach ([
}
}
$adSizesDsl = 'ads and ad_sizes where issue(125)';
$adSizesAst = $parser->parse($adSizesDsl);
$adSizesValidation = $validator->validate($adSizesAst);
if (($adSizesValidation['errors'] ?? []) !== []) {
fwrite(STDERR, "Expected valid DSL {$adSizesDsl}: " . json_encode($adSizesValidation['errors']) . "\n");
exit(1);
}
$adSizesSql = (string) ($compiler->compile($adSizesAst)['sql'] ?? '');
foreach ([
'INNER JOIN `ad_sizes` AS s_ad_sizes ON s_ads.`AdSize` = s_ad_sizes.`SizeName`',
's_ad_sizes.`Width` AS `ad_sizes.Width`',
] as $needle) {
if (strpos($adSizesSql, $needle) === false) {
fwrite(STDERR, "Expected Ad_Sizes SQL to contain {$needle}\n{$adSizesSql}\n");
exit(1);
}
}
$reverseAdSizesDsl = 'ad_sizes and ads where ad_sizes.SizeName = ads.AdSize';
$reverseAdSizesAst = $parser->parse($reverseAdSizesDsl);
$reverseAdSizesValidation = $validator->validate($reverseAdSizesAst);
if (($reverseAdSizesValidation['errors'] ?? []) !== []) {
fwrite(STDERR, "Expected valid reverse DSL {$reverseAdSizesDsl}: " . json_encode($reverseAdSizesValidation['errors']) . "\n");
exit(1);
}
$reverseAdSizesSql = (string) ($compiler->compile($reverseAdSizesAst)['sql'] ?? '');
if (strpos($reverseAdSizesSql, 'ON s_ad_sizes.`SizeName` = s_ads.`AdSize`') === false) {
fwrite(STDERR, "Expected reverse Ad_Sizes implied join\n{$reverseAdSizesSql}\n");
exit(1);
}
$quotedFieldDsl = 'advertisers where advertisers.`IsLapsed?` = true';
$quotedFieldAst = $parser->parse($quotedFieldDsl);
$quotedFieldValidation = $validator->validate($quotedFieldAst);
@ -98,11 +131,25 @@ if (($quotedFieldValidation['errors'] ?? []) !== []) {
exit(1);
}
$quotedFieldSql = (string) ($compiler->compile($quotedFieldAst)['sql'] ?? '');
if (strpos($quotedFieldSql, 's_advertisers.`IsLapsed?` = ?') === false) {
if (strpos($quotedFieldSql, "COALESCE(s_advertisers.`IsLapsed?`, '') = ?") === false) {
fwrite(STDERR, "Expected quoted physical field to compile directly\n{$quotedFieldSql}\n");
exit(1);
}
$notContainsDsl = "advertisers where not (advertisers.AdvertiserName contains 'discount')";
$notContainsSql = (string) ($compiler->compile($parser->parse($notContainsDsl))['sql'] ?? '');
if (strpos($notContainsSql, "NOT (COALESCE(s_advertisers.`AdvertiserName`, '') LIKE ?)") === false) {
fwrite(STDERR, "Expected negated contains to include NULL values via empty-string normalization\n{$notContainsSql}\n");
exit(1);
}
$fieldComparisonDsl = 'contacts and accounts where contacts.account_id = accounts.id';
$fieldComparisonSql = (string) ($compiler->compile($parser->parse($fieldComparisonDsl))['sql'] ?? '');
if (strpos($fieldComparisonSql, "COALESCE(s_contacts.`account_id`, '') = COALESCE(s_accounts.`id`, '')") === false) {
fwrite(STDERR, "Expected field comparison to normalize NULL values to empty strings\n{$fieldComparisonSql}\n");
exit(1);
}
$issueCases = [
'issues where issue(202605)' => 's_issues.`ID` = ?',
'articles where issue(202605)' => 'p_article_issue.`Issue` = ?',

View File

@ -124,6 +124,13 @@ foreach ($cases as $case) {
if (strpos($compiled['sql'], 'SELECT ') !== 0) {
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']];
}
$countable = $compiler->compileCountable($ast);
if (strpos($countable['sql'], 'SELECT 1 FROM ') !== 0) {
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Count compilation did not produce narrow SELECT']];
}
if (strpos($countable['sql'], ' AS `') !== false) {
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Count compilation should not project source fields']];
}
}
}

View File

@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Application\SourceMetadataProvider;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseRouter;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
/** @param mixed $actual @param mixed $expected */
function feca_assert_same($actual, $expected, string $message): void
{
if ($actual !== $expected) {
fwrite(
STDERR,
$message . "\nExpected: " . json_encode($expected) . "\nActual: " . json_encode($actual) . "\n"
);
exit(1);
}
}
function feca_assert_contains(string $haystack, string $needle, string $message): void
{
if (strpos($haystack, $needle) === false) {
fwrite(STDERR, $message . "\nMissing: {$needle}\nSQL: {$haystack}\n");
exit(1);
}
}
$parser = new DslParser();
$customMetadata = new class implements SourceMetadataProvider {
/** @var array<string, list<string>> */
private array $fields = [
'MixedSchema.MixedTable' => ['MixedId', 'Label'],
'OtherSchema.OtherTable' => ['ParentId', 'Description'],
];
public function sourceExists(string $source): bool
{
return isset($this->fields[$source]);
}
public function isBuiltInSource(string $source): bool
{
return false;
}
public function sourceFields(string $source): array
{
return $this->fields[$source] ?? [];
}
public function hasEmailField(string $source): bool
{
return false;
}
public function joinPath(string $left, string $right): ?array
{
if ($left === 'MixedSchema.MixedTable' && $right === 'OtherSchema.OtherTable') {
return [
'left' => 'MixedSchema.MixedTable.MixedId',
'right' => 'OtherSchema.OtherTable.ParentId',
];
}
return null;
}
public function allKnownSources(): array
{
return array_keys($this->fields);
}
public function sourceTable(string $source): ?string
{
return $this->sourceExists($source) ? $source : null;
}
public function fieldSql(string $source, string $field, string $alias): ?string
{
if (!in_array($field, $this->fields[$source] ?? [], true)) {
return null;
}
return $alias . '.`' . str_replace('`', '``', $field) . '`';
}
};
$customDsl = 'MixedSchema.MixedTable and OtherSchema.OtherTable '
. 'where MixedSchema.MixedTable.MixedId = OtherSchema.OtherTable.ParentId';
$customAst = $parser->parse($customDsl);
feca_assert_same(
$customAst['sources'],
['MixedSchema.MixedTable', 'OtherSchema.OtherTable'],
'Expected custom schema and table case to be preserved'
);
feca_assert_same(
$customAst['where'][0]['lhs']['source'] ?? null,
'MixedSchema.MixedTable',
'Expected left field source case to be preserved'
);
feca_assert_same(
$customAst['where'][0]['rhs']['source'] ?? null,
'OtherSchema.OtherTable',
'Expected right field source case to be preserved'
);
$customValidator = new DslValidator($customMetadata);
$customValidation = $customValidator->validate($customAst);
feca_assert_same($customValidation['errors'], [], 'Expected two custom sources to validate');
$customSql = (string) ((new DslCompiler($customMetadata))->compile($customAst)['sql'] ?? '');
feca_assert_contains(
$customSql,
'INNER JOIN `OtherSchema`.`OtherTable` AS s_otherschema_othertable '
. 'ON s_mixedschema_mixedtable.`MixedId` = s_otherschema_othertable.`ParentId`',
'Expected schema-qualified join references to split at their final dot'
);
feca_assert_contains(
$customSql,
"COALESCE(s_mixedschema_mixedtable.`MixedId`, '') = "
. "COALESCE(s_otherschema_othertable.`ParentId`, '')",
'Expected schema-qualified field comparison to use NULL-as-empty semantics'
);
$comparisonMetadata = new class implements SourceMetadataProvider {
public function sourceExists(string $source): bool
{
return $source === 'records';
}
public function isBuiltInSource(string $source): bool
{
return $source === 'records';
}
public function sourceFields(string $source): array
{
return $source === 'records' ? ['left_value', 'right_value'] : [];
}
public function hasEmailField(string $source): bool
{
return false;
}
public function joinPath(string $left, string $right): ?array
{
return null;
}
public function allKnownSources(): array
{
return ['records'];
}
public function sourceTable(string $source): ?string
{
return $source === 'records' ? 'records' : null;
}
public function fieldSql(string $source, string $field, string $alias): ?string
{
if ($source !== 'records' || !in_array($field, $this->sourceFields($source), true)) {
return null;
}
return $alias . '.`' . $field . '`';
}
};
$comparisonCompiler = new DslCompiler($comparisonMetadata);
$literalCases = [
"records.left_value = 'text'" => ["COALESCE(s_records.`left_value`, '') = ?", 'text'],
"records.left_value != 'text'" => ["COALESCE(s_records.`left_value`, '') != ?", 'text'],
"records.left_value contains 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", '%text%'],
"records.left_value starts-with 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", 'text%'],
"records.left_value ends-with 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", '%text'],
];
foreach ($literalCases as $predicate => [$sqlNeedle, $expectedParam]) {
$compiled = $comparisonCompiler->compile($parser->parse('records where ' . $predicate));
feca_assert_contains((string) $compiled['sql'], $sqlNeedle, 'Expected literal comparison to normalize NULL');
feca_assert_same($compiled['params'], [$expectedParam], 'Expected literal comparison parameter');
}
foreach (['=', '!='] as $operator) {
$compiled = $comparisonCompiler->compile(
$parser->parse("records where records.left_value {$operator} records.right_value")
);
feca_assert_contains(
(string) $compiled['sql'],
"COALESCE(s_records.`left_value`, '') {$operator} COALESCE(s_records.`right_value`, '')",
'Expected field comparison to normalize NULL on both sides'
);
feca_assert_same($compiled['params'], [], 'Expected no field comparison parameters');
}
$router = new class implements DatabaseRouter {
public function mailshotsPdo(): PDO
{
throw new RuntimeException('Unexpected mailshots database access');
}
public function membersPdo(): PDO
{
throw new RuntimeException('Unexpected members database access');
}
public function mailshotsDbName(): string
{
return 'mailshots_test';
}
public function membersDbName(): string
{
return 'members_test';
}
public function fenDbName(): string
{
return 'FenCaseSensitive';
}
};
$databaseMetadata = new DatabaseSourceMetadataProvider($router);
feca_assert_same($databaseMetadata->sourceExists('ad_sizes'), true, 'Expected ad_sizes to be a known source');
feca_assert_same($databaseMetadata->isBuiltInSource('ad_sizes'), true, 'Expected ad_sizes to be built in');
feca_assert_same(
$databaseMetadata->sourceTable('ad_sizes'),
'FenCaseSensitive.Ad_Sizes',
'Expected ad_sizes to map to the configured FEN database and physical table'
);
feca_assert_same(
$databaseMetadata->joinPath('ads', 'ad_sizes'),
['left' => 'ads.AdSize', 'right' => 'ad_sizes.SizeName'],
'Expected forward ads/ad_sizes join metadata'
);
feca_assert_same(
$databaseMetadata->joinPath('ad_sizes', 'ads'),
['left' => 'ad_sizes.SizeName', 'right' => 'ads.AdSize'],
'Expected reverse ads/ad_sizes join metadata'
);
echo "Recent DSL change regression tests passed\n";

View File

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
$GLOBALS['feca_test_actions'] = [];
$GLOBALS['feca_test_mailer'] = null;
$GLOBALS['feca_test_last_mail'] = null;
if (!function_exists('add_action')) {
function add_action(string $hook, callable $callback): void
{
$GLOBALS['feca_test_actions'][$hook][] = $callback;
}
}
if (!function_exists('remove_action')) {
function remove_action(string $hook, callable $callback): void
{
if (!isset($GLOBALS['feca_test_actions'][$hook])) {
return;
}
$GLOBALS['feca_test_actions'][$hook] = array_values(array_filter(
$GLOBALS['feca_test_actions'][$hook],
static fn(callable $registered): bool => $registered !== $callback
));
}
}
if (!function_exists('wp_mail')) {
function wp_mail($to, string $subject, string $message, $headers = '', $attachments = []): bool
{
$mailer = new FecaTestMailer();
foreach ($GLOBALS['feca_test_actions']['phpmailer_init'] ?? [] as $callback) {
$callback($mailer);
}
$headersList = is_array($headers) ? $headers : preg_split('/\r\n|\r|\n/', (string) $headers);
foreach ($headersList ?: [] as $header) {
$header = trim((string) $header);
if (stripos($header, 'Bcc:') === 0) {
$mailer->bcc[] = trim(substr($header, 4));
}
if (stripos($header, 'Cc:') === 0) {
$mailer->cc[] = trim(substr($header, 3));
}
}
$mailer->to = is_array($to) ? array_values($to) : [(string) $to];
$mailer->Subject = $subject;
$mailer->Body = $message;
$mailer->sentMime = "Date: Fri, 26 Jun 2026 10:00:00 +0000\r\n"
. "Message-ID: <fixture@example.org>\r\n"
. "To: " . implode(', ', $mailer->to) . "\r\n"
. "Cc: " . implode(', ', $mailer->cc) . "\r\n"
. "Subject: " . $subject . "\r\n\r\n"
. $message;
$GLOBALS['feca_test_mailer'] = $mailer;
$GLOBALS['feca_test_last_mail'] = [
'to' => $to,
'subject' => $subject,
'message' => $message,
'headers' => $headers,
'attachments' => $attachments,
];
return true;
}
}
final class FecaTestMailer
{
public string $Host = '';
public int $Port = 0;
public bool $SMTPAuth = false;
public string $Username = '';
public string $Password = '';
public string $Helo = '';
public string $CharSet = '';
public string $SMTPSecure = '';
public string $MessageID = '';
public bool $SMTPAutoTLS = false;
public string $ErrorInfo = '';
public string $Subject = '';
public string $Body = '';
public string $sentMime = '';
/** @var list<string> */
public array $to = [];
/** @var list<string> */
public array $cc = [];
/** @var list<string> */
public array $bcc = [];
/** @var list<array{bytes:string,filename:string,encoding:string,mime_type:string}> */
public array $stringAttachments = [];
public function isSMTP(): void
{
}
public function setFrom(string $address, string $name = '', bool $auto = true): void
{
}
public function addStringAttachment(string $bytes, string $filename, string $encoding, string $mimeType): void
{
$this->stringAttachments[] = [
'bytes' => $bytes,
'filename' => $filename,
'encoding' => $encoding,
'mime_type' => $mimeType,
];
}
public function getSentMIMEMessage(): string
{
return $this->sentMime;
}
}
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\WordPressSmtpSender;
$sender = new WordPressSmtpSender();
$binaryPdfBytes = "%PDF-1.7\n" . "\x00\x01\x80\xff" . "\n%%EOF";
$result = $sender->send(
[
'smtp_host' => 'smtp.example.org',
'smtp_port' => 587,
'smtp_user' => 'editor@example.org',
'smtp_password' => 'secret',
'smtp_from_email' => 'Editor@Example.ORG',
'smtp_from_name' => 'Editor',
'smtp_require_tls' => false,
],
['to@example.org'],
['copy@example.org'],
['hidden@example.org'],
'Subject',
'<p>Hello</p>',
'reply@example.org',
[[
'filename' => 'notice.pdf',
'mime_type' => 'application/pdf',
'content_bytes' => $binaryPdfBytes,
]]
);
$mailer = $GLOBALS['feca_test_mailer'];
if (!$mailer instanceof FecaTestMailer) {
fwrite(STDERR, "WordPress mailer was not configured\n");
exit(1);
}
if ($mailer->Host !== 'smtp.example.org' || $mailer->Port !== 587 || $mailer->Username !== 'editor@example.org') {
fwrite(STDERR, "SMTP credentials were not applied to WordPress mailer\n");
exit(1);
}
if ($mailer->Helo !== 'example.org') {
fwrite(STDERR, "SMTP EHLO domain should be derived from From email\n");
exit(1);
}
if (preg_match('/^<\\d{14}\\.[a-f0-9]{32}@example\\.org>$/', $mailer->MessageID) !== 1) {
fwrite(STDERR, "Message-ID should be generated with the From email domain\n");
exit(1);
}
if ($mailer->bcc !== ['hidden@example.org']) {
fwrite(STDERR, "BCC recipients should still be delivered through WordPress mail\n");
exit(1);
}
if (preg_match('/^Bcc:/mi', (string) $result['raw_mime']) === 1) {
fwrite(STDERR, "Raw MIME should not expose Bcc header\n");
exit(1);
}
if ($mailer->stringAttachments === [] || $mailer->stringAttachments[0]['filename'] !== 'notice.pdf') {
fwrite(STDERR, "In-memory attachment was not added to WordPress mailer\n");
exit(1);
}
if (($mailer->stringAttachments[0]['bytes'] ?? '') !== $binaryPdfBytes) {
fwrite(STDERR, "Binary attachment bytes should be passed to PHPMailer unchanged\n");
exit(1);
}
if (($GLOBALS['feca_test_actions']['phpmailer_init'] ?? []) !== []) {
fwrite(STDERR, "phpmailer_init hook should be removed after send\n");
exit(1);
}
if (strpos((string) $result['raw_mime'], 'Message-ID: <fixture@example.org>') === false) {
fwrite(STDERR, "Raw MIME from WordPress mailer was not returned\n");
exit(1);
}
echo "WordPressSmtpSender regression test passed\n";