diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php index 9ab2dc0..264f7ac 100644 --- a/feca_mailshots_plugin/feca_mailshots_plugin.php +++ b/feca_mailshots_plugin/feca_mailshots_plugin.php @@ -3,7 +3,7 @@ * Plugin Name: FECA Mailshots * Plugin URI: https://fenedge.co.uk/ * Description: FECA mailshots plugin. - * Version: 1.0.31 + * Version: 1.0.33 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: FECA diff --git a/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php b/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php index ef1cff0..afb7cdc 100644 --- a/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/RunMailshotAdminPage.php @@ -231,10 +231,7 @@ final class RunMailshotAdminPage if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } - $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); - $result = $this->runService()->runMailshot($mailshotId); - $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); - $this->redirect($mailshotId); + $this->handleRunAction(static fn(self $page, int $mailshotId): array => $page->runService()->runMailshot($mailshotId)); } public function handleRunSelectedUi(): void @@ -242,11 +239,9 @@ final class RunMailshotAdminPage if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } - $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); - $selectedRecipientKeys = $this->selectedRecipientKeysFromRequest(); - $result = $this->runService()->runMailshotSelected($mailshotId, $selectedRecipientKeys); - $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); - $this->redirect($mailshotId); + $this->handleRunAction(static function (self $page, int $mailshotId): array { + return $page->runService()->runMailshotSelected($mailshotId, $page->selectedRecipientKeysFromRequest()); + }); } public function handleRetryFailedUi(): void @@ -254,10 +249,7 @@ final class RunMailshotAdminPage if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } - $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); - $result = $this->runService()->retryFailed($mailshotId); - $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); - $this->redirect($mailshotId); + $this->handleRunAction(static fn(self $page, int $mailshotId): array => $page->runService()->retryFailed($mailshotId)); } public function handleRetryRecipientUi(): void @@ -265,13 +257,52 @@ final class RunMailshotAdminPage if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { 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 $action */ + private function handleRunAction(callable $action): void + { $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); - $recipientKey = (string) ($this->wp->requestParam('recipient_key', '') ?? ''); - $result = $this->runService()->retryRecipient($mailshotId, $recipientKey); + $completed = false; + $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->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 { $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-run&mailshot_id=' . $mailshotId); diff --git a/feca_mailshots_plugin/src/Application/MailshotRunService.php b/feca_mailshots_plugin/src/Application/MailshotRunService.php index 1eab1da..8c8fc86 100644 --- a/feca_mailshots_plugin/src/Application/MailshotRunService.php +++ b/feca_mailshots_plugin/src/Application/MailshotRunService.php @@ -264,6 +264,16 @@ final class MailshotRunService /** @return 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 */ + private function runMailshotUnsafe(int $mailshotId): array { $mailshot = $this->mailshots->find($mailshotId); if ($mailshot === null) { @@ -286,6 +296,16 @@ final class MailshotRunService /** @param list $selectedRecipientKeys @return 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 $selectedRecipientKeys @return array */ + 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 !== '')); if ($selectedRecipientKeys === []) { @@ -323,6 +343,16 @@ final class MailshotRunService /** @return 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 */ + private function retryFailedUnsafe(int $mailshotId): array { $creds = $this->credentials->credentials(); if ($creds === null) { @@ -351,6 +381,16 @@ final class MailshotRunService /** @return 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 */ + private function retryRecipientUnsafe(int $mailshotId, string $recipientKey): array { $recipientKey = trim($recipientKey); if ($recipientKey === '') { diff --git a/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php b/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php index e3a175e..ed48c60 100644 --- a/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php +++ b/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php @@ -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 { $headers = []; - $fromHeader = $fromName !== '' ? sprintf('%s <%s>', $fromName, $from) : $from; + $fromHeader = $fromName !== '' ? sprintf('%s <%s>', $this->encodeHeaderValue($fromName), $from) : $from; $headers[] = 'From: ' . $fromHeader; $headers[] = 'To: ' . implode(', ', $to); if ($cc !== []) { @@ -81,7 +81,7 @@ final class BasicSmtpSender implements SmtpSender if ($replyTo !== null && trim($replyTo) !== '') { $headers[] = 'Reply-To: ' . trim($replyTo); } - $headers[] = 'Subject: ' . $subject; + $headers[] = 'Subject: ' . $this->encodeHeaderValue($subject); $headers[] = 'MIME-Version: 1.0'; if ($attachments === []) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; @@ -115,6 +115,39 @@ final class BasicSmtpSender implements SmtpSender 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'); diff --git a/results/manual-testing-1.md b/results/manual-testing-1.md index 31082cc..d1ff8c7 100644 --- a/results/manual-testing-1.md +++ b/results/manual-testing-1.md @@ -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 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" -* [ ] 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] 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] 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] 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. diff --git a/tests/unit/test_basic_smtp_sender_mime.php b/tests/unit/test_basic_smtp_sender_mime.php index f94e6ef..b616f5c 100644 --- a/tests/unit/test_basic_smtp_sender_mime.php +++ b/tests/unit/test_basic_smtp_sender_mime.php @@ -18,7 +18,7 @@ $plain = $buildMime->invoke( ['to@example.org'], [], [], - 'Subject', + "All Saint's café", '

Hello

', 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"); 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?= ') === false) { + fwrite(STDERR, "From display name missing expected RFC 2047 encoding\n"); + exit(1); +} $bytes = random_bytes(256 * 1024); $mime = $buildMime->invoke( @@ -61,4 +73,3 @@ if (strpos($mime, base64_encode(substr($bytes, 0, 24))) === false) { } echo "BasicSmtpSender MIME regression test passed\n"; -