diff --git a/inc/events-controllers/class_bento_email_handler.php b/inc/events-controllers/class_bento_email_handler.php index 3e91eb2..e6662fc 100644 --- a/inc/events-controllers/class_bento_email_handler.php +++ b/inc/events-controllers/class_bento_email_handler.php @@ -158,7 +158,14 @@ public function process_email_queue() { */ private function create_email_hash($email_data) { // Convert recipient(s) to a consistent format - $to = is_array($email_data['to']) ? implode(',', $email_data['to']) : $email_data['to']; + $normalized = $this->normalize_recipients($email_data['to'] ?? []); + if (!empty($normalized)) { + $to = implode(',', $normalized); + } else { + $to = is_array($email_data['to'] ?? null) + ? implode(',', $email_data['to']) + : ($email_data['to'] ?? ''); + } // Combine key elements of the email $hash_input = $to . '|' . @@ -179,6 +186,18 @@ private function send_via_bento($email_data) { $bento_secret_key = $options['bento_secret_key'] ?? ''; $from_email = $options['bento_from_email'] ?? get_option('admin_email'); $transactional_override = !empty($options['bento_transactional_override']) && $options['bento_transactional_override'] === '1'; + $reply_to_enabled = !isset($options['bento_enable_reply_to']) || $options['bento_enable_reply_to'] !== '0'; + + $recipients = $this->normalize_recipients($email_data['to'] ?? []); + if (empty($recipients)) { + Bento_Logger::log('Bento Email Handler: No valid recipients found'); + return false; + } + + $recipient_list = implode(', ', $recipients); + + $parsed_headers = $this->parse_headers($email_data['headers'] ?? []); + $reply_to = $this->parse_reply_to($parsed_headers); Bento_Logger::log('Bento Email Handler: Sending via API'); @@ -199,7 +218,7 @@ private function send_via_bento($email_data) { $body = array( 'emails' => array( array( - 'to' => $email_data['to'], + 'to' => $recipient_list, 'from' => $from_email, 'subject' => $email_data['subject'], 'html_body' => $email_data['message'], @@ -208,6 +227,10 @@ private function send_via_bento($email_data) { ) ); + if ($reply_to_enabled && !empty($reply_to)) { + $body['emails'][0]['reply_to'] = $reply_to; + } + Bento_Logger::log('Request body: ' . print_r($body, true)); // Get plugin version @@ -249,4 +272,125 @@ private function send_via_bento($email_data) { return true; } -} \ No newline at end of file + + /** + * Parse email headers into a name/value array. + */ + private function parse_headers($headers) { + if (empty($headers)) { + return []; + } + + if (is_string($headers)) { + $headers = explode("\n", str_replace("\r\n", "\n", $headers)); + } + + if (!is_array($headers)) { + return []; + } + + $parsed = []; + + foreach ($headers as $header) { + if (!is_string($header) || trim($header) === '') { + continue; + } + + if (strpos($header, ':') === false) { + continue; + } + + [$name, $value] = explode(':', $header, 2); + $parsed[strtolower(trim($name))] = trim($value); + } + + return $parsed; + } + + /** + * Extract and validate reply-to address from parsed headers. + */ + private function parse_reply_to($headers) { + $value = $headers['reply-to'] ?? null; + if (empty($value)) { + return null; + } + + $candidates = preg_split('/[,;]/', $value); + if ($candidates === false) { + $candidates = [$value]; + } + + foreach ($candidates as $candidate) { + $candidate = trim($candidate); + if ($candidate === '') { + continue; + } + + if (preg_match('/<([^>]+)>/', $candidate, $matches)) { + $email = trim($matches[1]); + } else { + $email = $candidate; + } + + if ($this->is_valid_email($email)) { + return $email; + } + } + + return null; + } + + /** + * Normalize recipient list into valid email addresses. + */ + private function normalize_recipients($recipients) { + if (empty($recipients)) { + return []; + } + + if (is_string($recipients)) { + $recipients = preg_split('/[,;]/', $recipients); + } + + if (!is_array($recipients)) { + $recipients = (array) $recipients; + } + + $normalized = []; + + foreach ($recipients as $recipient) { + if (!is_string($recipient)) { + continue; + } + + $recipient = trim($recipient); + if ($recipient === '') { + continue; + } + + if (preg_match('/<([^>]+)>/', $recipient, $matches)) { + $email = trim($matches[1]); + } else { + $email = $recipient; + } + + if ($this->is_valid_email($email)) { + $normalized[] = $email; + } + } + + return array_values(array_unique($normalized)); + } + + /** + * Validate email address using WordPress helpers when available. + */ + private function is_valid_email($email) { + if (function_exists('is_email')) { + return is_email($email); + } + + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } +} diff --git a/tests/Unit/BentoEmailHandlerTest.php b/tests/Unit/BentoEmailHandlerTest.php index 749e73b..8267d20 100644 --- a/tests/Unit/BentoEmailHandlerTest.php +++ b/tests/Unit/BentoEmailHandlerTest.php @@ -114,3 +114,130 @@ $queue = get_option('bento_email_queue', []); expect($queue)->toBe([]); }); + +test('Bento email handler forwards reply-to headers', function () { + update_option('bento_settings', [ + 'bento_enable_transactional' => '1', + 'bento_site_key' => 'site', + 'bento_publishable_key' => 'pub', + 'bento_secret_key' => 'sec', + 'bento_enable_reply_to' => '1', + ]); + + $handler = new Bento_Email_Handler(); + + $email = [ + 'to' => 'reply@example.com', + 'subject' => 'Subject', + 'message' => 'Body', + 'headers' => ['Reply-To: reply@example.com'], + ]; + + update_option('bento_email_queue', [[ + 'email_data' => $email, + 'timestamp' => time(), + 'hash' => md5('reply@example.com|Subject'), + ]]); + + $handler->process_email_queue(); + + global $__wp_test_state; + $payload = json_decode($__wp_test_state['remote_posts'][0]['args']['body'], true); + + expect($payload['emails'][0]['reply_to'])->toBe('reply@example.com'); +}); + +test('Bento email handler skips invalid reply-to headers', function () { + update_option('bento_settings', [ + 'bento_enable_transactional' => '1', + 'bento_site_key' => 'site', + 'bento_publishable_key' => 'pub', + 'bento_secret_key' => 'sec', + 'bento_enable_reply_to' => '1', + ]); + + $handler = new Bento_Email_Handler(); + + $email = [ + 'to' => 'reply@example.com', + 'subject' => 'Subject', + 'message' => 'Body', + 'headers' => ['Reply-To: not-an-email'], + ]; + + update_option('bento_email_queue', [[ + 'email_data' => $email, + 'timestamp' => time(), + 'hash' => md5('reply@example.com|Subject'), + ]]); + + $handler->process_email_queue(); + + global $__wp_test_state; + $payload = json_decode($__wp_test_state['remote_posts'][0]['args']['body'], true); + + expect($payload['emails'][0])->not->toHaveKey('reply_to'); +}); + +test('Bento email handler normalizes recipient list', function () { + update_option('bento_settings', [ + 'bento_enable_transactional' => '1', + 'bento_site_key' => 'site', + 'bento_publishable_key' => 'pub', + 'bento_secret_key' => 'sec', + ]); + + $handler = new Bento_Email_Handler(); + + $email = [ + 'to' => 'Jane Doe , invalid, john@example.com', + 'subject' => 'Subject', + 'message' => 'Body', + 'headers' => [], + ]; + + update_option('bento_email_queue', [[ + 'email_data' => $email, + 'timestamp' => time(), + 'hash' => md5('jane@example.com,john@example.com|Subject'), + ]]); + + $handler->process_email_queue(); + + global $__wp_test_state; + $payload = json_decode($__wp_test_state['remote_posts'][0]['args']['body'], true); + + expect($payload['emails'][0]['to'])->toBe('jane@example.com, john@example.com'); +}); + +test('Bento email handler accepts case-insensitive reply-to headers', function () { + update_option('bento_settings', [ + 'bento_enable_transactional' => '1', + 'bento_site_key' => 'site', + 'bento_publishable_key' => 'pub', + 'bento_secret_key' => 'sec', + 'bento_enable_reply_to' => '1', + ]); + + $handler = new Bento_Email_Handler(); + + $email = [ + 'to' => 'reply@example.com', + 'subject' => 'Subject', + 'message' => 'Body', + 'headers' => ['REPLY-TO: reply@example.com'], + ]; + + update_option('bento_email_queue', [[ + 'email_data' => $email, + 'timestamp' => time(), + 'hash' => md5('reply@example.com|Subject'), + ]]); + + $handler->process_email_queue(); + + global $__wp_test_state; + $payload = json_decode($__wp_test_state['remote_posts'][0]['args']['body'], true); + + expect($payload['emails'][0]['reply_to'])->toBe('reply@example.com'); +});