Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 147 additions & 3 deletions inc/events-controllers/class_bento_email_handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 . '|' .
Expand All @@ -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');
Expand All @@ -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'],
Expand All @@ -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
Expand Down Expand Up @@ -249,4 +272,125 @@ private function send_via_bento($email_data) {

return true;
}
}

/**
* 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;
}
}
127 changes: 127 additions & 0 deletions tests/Unit/BentoEmailHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jane@example.com>, 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'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Hash calculation may not match the actual implementation if recipient normalization happens before hashing. Does the hash calculation in the handler use the original 'to' field or the normalized recipient list?

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/Unit/BentoEmailHandlerTest.php
Line: 202:202

Comment:
**logic:** Hash calculation may not match the actual implementation if recipient normalization happens before hashing. Does the hash calculation in the handler use the original 'to' field or the normalized recipient list?

How can I resolve this? If you propose a fix, please make it concise.

]]);

$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');
});