diff --git a/README.md b/README.md index a9c109e..a1b3a7c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ 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 in-memory attachments, including rendered PDFs, are added to PHPMailer as string attachments. + ## Important References - Environment baseline: `requirements/environment.md` diff --git a/dist/feca_mailshots_plugin-1.1.6.zip b/dist/feca_mailshots_plugin-1.1.6.zip index 3539afe..7ef8b58 100644 Binary files a/dist/feca_mailshots_plugin-1.1.6.zip and b/dist/feca_mailshots_plugin-1.1.6.zip differ diff --git a/dist/feca_mailshots_plugin-1.1.8.zip b/dist/feca_mailshots_plugin-1.1.8.zip new file mode 100644 index 0000000..67a5b4f Binary files /dev/null and b/dist/feca_mailshots_plugin-1.1.8.zip differ diff --git a/docs/todo.md b/docs/todo.md index ee17a2f..0e7c908 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -3,7 +3,3 @@ 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 - - - -tig diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php index e6537c0..115e1f3 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.1.6 + * Version: 1.1.8 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: FECA diff --git a/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php b/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php index c04fa91..944b7e9 100644 --- a/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php +++ b/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php @@ -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);}' diff --git a/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php index d415051..a0a833a 100644 --- a/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php @@ -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 @@ -208,7 +209,8 @@ final class MailshotTestAdminPage echo ''; echo ''; echo ''; - echo '
'; + echo '
'; + echo '
'; echo ''; echo '
'; echo ''; @@ -218,17 +220,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 '})();'; @@ -358,11 +363,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 +420,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 +434,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)); diff --git a/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php b/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php deleted file mode 100644 index 7c36b0f..0000000 --- a/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.php +++ /dev/null @@ -1,238 +0,0 @@ -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, $this->dotStuff($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 $to @param list $cc @param list $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'; - $headers[] = 'Content-Transfer-Encoding: quoted-printable'; - return implode("\r\n", $headers) . "\r\n\r\n" . $this->encodeQuotedPrintable($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: quoted-printable' . "\r\n\r\n"; - $mime .= $this->encodeQuotedPrintable($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); - } - } - - private function encodeQuotedPrintable(string $body): string - { - $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body) ?? $body; - return quoted_printable_encode($body); - } - - private function dotStuff(string $data): string - { - $data = preg_replace("/\r\n|\r|\n/", "\r\n", $data) ?? $data; - return preg_replace('/(?m)^\./', '..', $data) ?? $data; - } - - /** @param list $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 $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; - } - } - } -} diff --git a/feca_mailshots_plugin/src/Infrastructure/WordPressSmtpSender.php b/feca_mailshots_plugin/src/Infrastructure/WordPressSmtpSender.php new file mode 100644 index 0000000..2a1cd7b --- /dev/null +++ b/feca_mailshots_plugin/src/Infrastructure/WordPressSmtpSender.php @@ -0,0 +1,159 @@ +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 $cc @param list $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; + } +} diff --git a/feca_mailshots_plugin/src/Plugin.php b/feca_mailshots_plugin/src/Plugin.php index a798830..58427de 100644 --- a/feca_mailshots_plugin/src/Plugin.php +++ b/feca_mailshots_plugin/src/Plugin.php @@ -26,12 +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\WordPressSmtpSender; use FecaMailshots\Infrastructure\WordPressSaltSecretKeyProvider; use FecaMailshots\Repository\AttachmentRepository; use FecaMailshots\Repository\LastRunRepository; @@ -98,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), diff --git a/tests/e2e/specs/new-features-selected-rows.spec.mjs b/tests/e2e/specs/new-features-selected-rows.spec.mjs index d22da03..af10565 100644 --- a/tests/e2e/specs/new-features-selected-rows.spec.mjs +++ b/tests/e2e/specs/new-features-selected-rows.spec.mjs @@ -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 { diff --git a/tests/unit/test_basic_smtp_sender_mime.php b/tests/unit/test_basic_smtp_sender_mime.php deleted file mode 100644 index 32a921e..0000000 --- a/tests/unit/test_basic_smtp_sender_mime.php +++ /dev/null @@ -1,114 +0,0 @@ -getMethod('buildMime'); -$buildMime->setAccessible(true); - -$plain = $buildMime->invoke( - $sender, - 'from@example.org', - 'From Name', - ['to@example.org'], - [], - [], - "All Saint's café", - '

Hello

', - 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?= ') === false) { - fwrite(STDERR, "From display name missing expected RFC 2047 encoding\n"); - exit(1); -} -if (strpos($plain, 'Content-Transfer-Encoding: quoted-printable') === false) { - fwrite(STDERR, "Plain MIME rendering should use quoted-printable HTML body encoding\n"); - exit(1); -} - -$longHtml = '

' . str_repeat('LongHtmlSegment', 120) . '

'; -$longPlain = $buildMime->invoke( - $sender, - 'from@example.org', - 'From Name', - ['to@example.org'], - [], - [], - 'Subject', - $longHtml, - null, - [] -); - -if (!is_string($longPlain)) { - fwrite(STDERR, "Long plain MIME rendering did not return a string\n"); - exit(1); -} -foreach (preg_split('/\r\n/', $longPlain) ?: [] as $line) { - if (strlen($line) > 998) { - fwrite(STDERR, "Plain MIME contains an RFC 5322 overlong line\n"); - exit(1); - } -} - -$bytes = random_bytes(256 * 1024); -$mime = $buildMime->invoke( - $sender, - 'from@example.org', - 'From Name', - ['to@example.org'], - [], - [], - 'Subject', - '

Hello

', - 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, 'Content-Transfer-Encoding: quoted-printable') === false) { - fwrite(STDERR, "Multipart HTML part should use quoted-printable body encoding\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); -} -foreach (preg_split('/\r\n/', $mime) ?: [] as $line) { - if (strlen($line) > 998) { - fwrite(STDERR, "Multipart MIME contains an RFC 5322 overlong line\n"); - exit(1); - } -} - -echo "BasicSmtpSender MIME regression test passed\n"; diff --git a/tests/unit/test_wordpress_smtp_sender.php b/tests/unit/test_wordpress_smtp_sender.php new file mode 100644 index 0000000..c0e7547 --- /dev/null +++ b/tests/unit/test_wordpress_smtp_sender.php @@ -0,0 +1,186 @@ + $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: \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 */ + public array $to = []; + /** @var list */ + public array $cc = []; + /** @var list */ + public array $bcc = []; + /** @var list */ + 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(); +$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', + '

Hello

', + 'reply@example.org', + [[ + 'filename' => 'notice.pdf', + 'mime_type' => 'application/pdf', + 'content_bytes' => '%PDF fixture', + ]] +); + +$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 (($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: ') === false) { + fwrite(STDERR, "Raw MIME from WordPress mailer was not returned\n"); + exit(1); +} + +echo "WordPressSmtpSender regression test passed\n";