failed attempt to avoid wordpress critical error sending test mailshot

This commit is contained in:
Adrian Stephens 2026-05-16 16:54:47 +01:00
parent ce50edc04a
commit 10395f0480
6 changed files with 140 additions and 22 deletions

View File

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

View File

@ -231,10 +231,7 @@ final class RunMailshotAdminPage
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return; return;
} }
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $this->handleRunAction(static fn(self $page, int $mailshotId): array => $page->runService()->runMailshot($mailshotId));
$result = $this->runService()->runMailshot($mailshotId);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId);
} }
public function handleRunSelectedUi(): void public function handleRunSelectedUi(): void
@ -242,11 +239,9 @@ final class RunMailshotAdminPage
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return; return;
} }
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $this->handleRunAction(static function (self $page, int $mailshotId): array {
$selectedRecipientKeys = $this->selectedRecipientKeysFromRequest(); return $page->runService()->runMailshotSelected($mailshotId, $page->selectedRecipientKeysFromRequest());
$result = $this->runService()->runMailshotSelected($mailshotId, $selectedRecipientKeys); });
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId);
} }
public function handleRetryFailedUi(): void public function handleRetryFailedUi(): void
@ -254,10 +249,7 @@ final class RunMailshotAdminPage
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return; return;
} }
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $this->handleRunAction(static fn(self $page, int $mailshotId): array => $page->runService()->retryFailed($mailshotId));
$result = $this->runService()->retryFailed($mailshotId);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId);
} }
public function handleRetryRecipientUi(): void public function handleRetryRecipientUi(): void
@ -265,13 +257,52 @@ final class RunMailshotAdminPage
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return; return;
} }
$this->handleRunAction(static function (self $page, int $mailshotId): array {
$recipientKey = (string) ($page->wp->requestParam('recipient_key', '') ?? '');
return $page->runService()->retryRecipient($mailshotId, $recipientKey);
});
}
/** @param callable(self,int):array<string,mixed> $action */
private function handleRunAction(callable $action): void
{
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$recipientKey = (string) ($this->wp->requestParam('recipient_key', '') ?? ''); $completed = false;
$result = $this->runService()->retryRecipient($mailshotId, $recipientKey); $fatalHandled = false;
register_shutdown_function(function () use (&$completed, &$fatalHandled, $mailshotId): void {
if ($completed || $fatalHandled) {
return;
}
$error = error_get_last();
if (!is_array($error) || !in_array((int) ($error['type'] ?? 0), [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
return;
}
$fatalHandled = true;
$this->storeRunFailure('Run action failed: ' . (string) ($error['message'] ?? 'Unknown fatal error.'));
$this->redirect($mailshotId);
});
try {
$result = $action($this, $mailshotId);
} catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Run action failed: ' . $e->getMessage()]];
}
$completed = true;
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId); $this->redirect($mailshotId);
} }
private function storeRunFailure(string $message): void
{
try {
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => [$message]]);
} catch (\Throwable $e) {
error_log('Mailshots run failure could not be stored: ' . $e->getMessage());
}
}
private function redirect(int $mailshotId): void private function redirect(int $mailshotId): void
{ {
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-run&mailshot_id=' . $mailshotId); $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-run&mailshot_id=' . $mailshotId);

View File

@ -264,6 +264,16 @@ final class MailshotRunService
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function runMailshot(int $mailshotId): array public function runMailshot(int $mailshotId): array
{
try {
return $this->runMailshotUnsafe($mailshotId);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Run mailshot failed: ' . $e->getMessage()]];
}
}
/** @return array<string, mixed> */
private function runMailshotUnsafe(int $mailshotId): array
{ {
$mailshot = $this->mailshots->find($mailshotId); $mailshot = $this->mailshots->find($mailshotId);
if ($mailshot === null) { if ($mailshot === null) {
@ -286,6 +296,16 @@ final class MailshotRunService
/** @param list<string> $selectedRecipientKeys @return array<string, mixed> */ /** @param list<string> $selectedRecipientKeys @return array<string, mixed> */
public function runMailshotSelected(int $mailshotId, array $selectedRecipientKeys): array public function runMailshotSelected(int $mailshotId, array $selectedRecipientKeys): array
{
try {
return $this->runMailshotSelectedUnsafe($mailshotId, $selectedRecipientKeys);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Run selected mailshot failed: ' . $e->getMessage()]];
}
}
/** @param list<string> $selectedRecipientKeys @return array<string, mixed> */
private function runMailshotSelectedUnsafe(int $mailshotId, array $selectedRecipientKeys): array
{ {
$selectedRecipientKeys = array_values(array_filter(array_map(static fn($v): string => trim((string) $v), $selectedRecipientKeys), static fn(string $v): bool => $v !== '')); $selectedRecipientKeys = array_values(array_filter(array_map(static fn($v): string => trim((string) $v), $selectedRecipientKeys), static fn(string $v): bool => $v !== ''));
if ($selectedRecipientKeys === []) { if ($selectedRecipientKeys === []) {
@ -323,6 +343,16 @@ final class MailshotRunService
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function retryFailed(int $mailshotId): array public function retryFailed(int $mailshotId): array
{
try {
return $this->retryFailedUnsafe($mailshotId);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Retry failed sends failed: ' . $e->getMessage()]];
}
}
/** @return array<string, mixed> */
private function retryFailedUnsafe(int $mailshotId): array
{ {
$creds = $this->credentials->credentials(); $creds = $this->credentials->credentials();
if ($creds === null) { if ($creds === null) {
@ -351,6 +381,16 @@ final class MailshotRunService
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function retryRecipient(int $mailshotId, string $recipientKey): array public function retryRecipient(int $mailshotId, string $recipientKey): array
{
try {
return $this->retryRecipientUnsafe($mailshotId, $recipientKey);
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Retry recipient failed: ' . $e->getMessage()]];
}
}
/** @return array<string, mixed> */
private function retryRecipientUnsafe(int $mailshotId, string $recipientKey): array
{ {
$recipientKey = trim($recipientKey); $recipientKey = trim($recipientKey);
if ($recipientKey === '') { if ($recipientKey === '') {

View File

@ -69,7 +69,7 @@ final class BasicSmtpSender implements SmtpSender
private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo, array $attachments): string private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo, array $attachments): string
{ {
$headers = []; $headers = [];
$fromHeader = $fromName !== '' ? sprintf('%s <%s>', $fromName, $from) : $from; $fromHeader = $fromName !== '' ? sprintf('%s <%s>', $this->encodeHeaderValue($fromName), $from) : $from;
$headers[] = 'From: ' . $fromHeader; $headers[] = 'From: ' . $fromHeader;
$headers[] = 'To: ' . implode(', ', $to); $headers[] = 'To: ' . implode(', ', $to);
if ($cc !== []) { if ($cc !== []) {
@ -81,7 +81,7 @@ final class BasicSmtpSender implements SmtpSender
if ($replyTo !== null && trim($replyTo) !== '') { if ($replyTo !== null && trim($replyTo) !== '') {
$headers[] = 'Reply-To: ' . trim($replyTo); $headers[] = 'Reply-To: ' . trim($replyTo);
} }
$headers[] = 'Subject: ' . $subject; $headers[] = 'Subject: ' . $this->encodeHeaderValue($subject);
$headers[] = 'MIME-Version: 1.0'; $headers[] = 'MIME-Version: 1.0';
if ($attachments === []) { if ($attachments === []) {
$headers[] = 'Content-Type: text/html; charset=UTF-8'; $headers[] = 'Content-Type: text/html; charset=UTF-8';
@ -115,6 +115,39 @@ final class BasicSmtpSender implements SmtpSender
return $mime; 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 private function encodeBase64Chunked(string $bytes): string
{ {
$stream = fopen('php://temp', 'w+b'); $stream = fopen('php://temp', 'w+b');

View File

@ -20,7 +20,10 @@
* [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council" gives too many (103) - need is_deleted filter generates error: "SQLSTATE[HY000]: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '='" * [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council" gives too many (103) - need is_deleted filter generates error: "SQLSTATE[HY000]: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '='"
* [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council and account-has-article-in-issue(125)" validates OK, but preview gives: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '=' * [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council and account-has-article-in-issue(125)" validates OK, but preview gives: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '='
* [X] issues.CopyDate and issue.PublicationDate render as e.g. "2026-02-23 00:00:00", would prefer to render dates as e.g. "Friday 23 February 2026" * [X] issues.CopyDate and issue.PublicationDate render as e.g. "2026-02-23 00:00:00", would prefer to render dates as e.g. "Friday 23 February 2026"
* [ ] I made an edit to a mailshot (), with a 9kb message. Save did not close the dialog - but did not show any errors. There was a long pause (long enough to start typing this report) before it appeared to have had an effect (mailshot saved message). But Edit button on Mailshot doesn't appear to do anything now browser shows page still loading. https://fenedge.co.uk is not responding. * [X] I made an edit to a mailshot (), with a 9kb message. Save did not close the dialog - but did not show any errors. There was a long pause (long enough to start typing this report) before it appeared to have had an effect (mailshot saved message). But Edit button on Mailshot doesn't appear to do anything now browser shows page still loading. https://fenedge.co.uk is not responding.
* [ ] Edit Data Source save DSL="ads and advertisers where issue(125)" takes a very long long time to repond. * [X] Edit Data Source save DSL="ads and advertisers where issue(125)" takes a very long long time to repond.
* [X] Downloaded merged pdf or .zip should be named after maiilshot purpose * [X] Downloaded merged pdf or .zip should be named after maiilshot purpose
* [X] Mailshot data sources. Add "Duplicate" button per row. * [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.

View File

@ -18,7 +18,7 @@ $plain = $buildMime->invoke(
['to@example.org'], ['to@example.org'],
[], [],
[], [],
'Subject', "All Saint's café",
'<p>Hello</p>', '<p>Hello</p>',
null, null,
[] []
@ -28,6 +28,18 @@ if (!is_string($plain) || strpos($plain, 'Content-Type: text/html; charset=UTF-8
fwrite(STDERR, "Plain MIME rendering missing HTML content-type header\n"); fwrite(STDERR, "Plain MIME rendering missing HTML content-type header\n");
exit(1); 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); $bytes = random_bytes(256 * 1024);
$mime = $buildMime->invoke( $mime = $buildMime->invoke(
@ -61,4 +73,3 @@ if (strpos($mime, base64_encode(substr($bytes, 0, 24))) === false) {
} }
echo "BasicSmtpSender MIME regression test passed\n"; echo "BasicSmtpSender MIME regression test passed\n";