Skip to content

Commit 08504d7

Browse files
committed
Fix placeholder replacement in one-time emails
- Add replace_one_time_placeholders() method to Admin_Email class - Replace placeholders AFTER applying header/footer so footer placeholders work - Check if recipient is existing subscriber for unsubscribe URL generation - For non-subscribers, unsubscribe placeholders are replaced with empty strings - Update tests to properly mock the new subscriber lookup flow Fixes issue where {unsubscribe_url} and other placeholders in email footer were not being replaced when sending one-time emails.
1 parent f8b0a89 commit 08504d7

2 files changed

Lines changed: 127 additions & 51 deletions

File tree

includes/Admin/class-admin-email.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,9 @@ private function handle_one_time_email(): void {
360360
// Apply header/footer for immediate sends.
361361
$body_with_wrapper = $this->apply_header_footer( $body, $settings );
362362

363+
// Replace subscriber placeholders (including those in header/footer).
364+
$body_with_wrapper = $this->replace_one_time_placeholders( $body_with_wrapper, $recipient_email, $recipient_name );
365+
363366
// Send immediately (via SMTP if configured, otherwise via PHP mail).
364367
$sent = $mailer->send( $recipient_email, $subject, $body_with_wrapper );
365368

@@ -448,6 +451,62 @@ private function handle_one_time_email(): void {
448451
}
449452
}
450453

454+
/**
455+
* Replace subscriber placeholders for one-time emails.
456+
*
457+
* Checks if the recipient is an existing subscriber and uses their data.
458+
* For non-subscribers, uses the provided name and removes unsubscribe links.
459+
*
460+
* @param string $content Email content.
461+
* @param string $recipient_email Recipient email address.
462+
* @param string $recipient_name Recipient name.
463+
* @return string Content with placeholders replaced.
464+
*/
465+
private function replace_one_time_placeholders( string $content, string $recipient_email, string $recipient_name ): string {
466+
global $wpdb;
467+
468+
// Check if recipient is an existing subscriber.
469+
$table_name = $wpdb->prefix . 'mskd_subscribers';
470+
$subscriber = $wpdb->get_row(
471+
$wpdb->prepare(
472+
"SELECT * FROM {$table_name} WHERE email = %s LIMIT 1",
473+
$recipient_email
474+
)
475+
);
476+
477+
if ( $subscriber && ! empty( $subscriber->unsubscribe_token ) ) {
478+
// Subscriber exists - use their data.
479+
$unsubscribe_url = add_query_arg(
480+
array(
481+
'mskd_unsubscribe' => $subscriber->unsubscribe_token,
482+
),
483+
home_url()
484+
);
485+
486+
$first_name = ! empty( $subscriber->first_name ) ? $subscriber->first_name : $recipient_name;
487+
$last_name = ! empty( $subscriber->last_name ) ? $subscriber->last_name : '';
488+
489+
$placeholders = array(
490+
'{first_name}' => $first_name,
491+
'{last_name}' => $last_name,
492+
'{email}' => $subscriber->email,
493+
'{unsubscribe_link}' => '<a href="' . esc_url( $unsubscribe_url ) . '">' . __( 'Unsubscribe', 'mail-system-by-katsarov-design' ) . '</a>',
494+
'{unsubscribe_url}' => $unsubscribe_url,
495+
);
496+
} else {
497+
// Not a subscriber - use provided data and remove unsubscribe links.
498+
$placeholders = array(
499+
'{first_name}' => $recipient_name,
500+
'{last_name}' => '',
501+
'{email}' => $recipient_email,
502+
'{unsubscribe_link}' => '',
503+
'{unsubscribe_url}' => '',
504+
);
505+
}
506+
507+
return str_replace( array_keys( $placeholders ), array_values( $placeholders ), $content );
508+
}
509+
451510
/**
452511
* Render the compose page.
453512
*

tests/Unit/OneTimeEmailTest.php

Lines changed: 68 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,31 @@ public function test_one_time_email_sends_successfully(): void {
179179
// Note: SMTP mailer uses PHPMailer directly, not wp_mail.
180180
// The mock PHPMailer in bootstrap.php returns true from send().
181181

182-
// Mock subscriber lookup (get_by_email returns null - subscriber doesn't exist).
182+
// Mock all get_row calls:
183+
// 1. replace_one_time_placeholders() checks if recipient is subscriber → null (not found)
184+
// 2. subscriber_service->get_by_email() in get_or_create() → null (not found)
185+
// 3. subscriber_service->get_by_id() after creating subscriber → subscriber object
186+
$call_count = 0;
187+
$subscriber_insert_id = 100;
183188
$wpdb->shouldReceive( 'get_row' )
184-
->once()
185-
->andReturn( null );
189+
->andReturnUsing( function () use ( &$call_count, $subscriber_insert_id ) {
190+
++$call_count;
191+
// First two calls return null (subscriber not found).
192+
if ( $call_count <= 2 ) {
193+
return null;
194+
}
195+
// Third call returns the newly created subscriber.
196+
return (object) array(
197+
'id' => $subscriber_insert_id,
198+
'email' => 'user@example.com',
199+
'first_name' => 'Test User',
200+
'last_name' => '',
201+
'status' => 'active',
202+
'unsubscribe_token' => 'test_token_123',
203+
);
204+
} );
186205

187-
// Mock subscriber creation (insert into subscribers table).
188-
$subscriber_insert_id = 100;
206+
// Mock subscriber creation (insert into subscribers table by get_or_create).
189207
$wpdb->shouldReceive( 'insert' )
190208
->once()
191209
->with(
@@ -198,18 +216,6 @@ public function test_one_time_email_sends_successfully(): void {
198216
return 1;
199217
} );
200218

201-
// Mock get_by_id after creation.
202-
$wpdb->shouldReceive( 'get_row' )
203-
->once()
204-
->andReturn( (object) array(
205-
'id' => $subscriber_insert_id,
206-
'email' => 'user@example.com',
207-
'first_name' => 'Test User',
208-
'last_name' => '',
209-
'status' => 'active',
210-
'unsubscribe_token' => 'test_token_123',
211-
) );
212-
213219
// Expect database inserts: campaigns table, then queue table.
214220
$wpdb->shouldReceive( 'insert' )
215221
->once()
@@ -295,13 +301,30 @@ public function test_one_time_email_logs_failure(): void {
295301
return $default;
296302
});
297303

298-
// Mock subscriber lookup (get_by_email returns null - subscriber doesn't exist).
304+
// Mock subscriber lookup for placeholder replacement (recipient is NOT a subscriber).
305+
// This is called by replace_one_time_placeholders() in Admin_Email.
306+
// Then subscriber_service->get_or_create() also does lookups.
307+
$call_count = 0;
308+
$subscriber_insert_id = 100;
299309
$wpdb->shouldReceive( 'get_row' )
300-
->once()
301-
->andReturn( null );
310+
->andReturnUsing( function () use ( &$call_count, $subscriber_insert_id ) {
311+
++$call_count;
312+
// First two calls return null (subscriber not found).
313+
if ( $call_count <= 2 ) {
314+
return null;
315+
}
316+
// Third call returns the newly created subscriber.
317+
return (object) array(
318+
'id' => $subscriber_insert_id,
319+
'email' => 'user@example.com',
320+
'first_name' => 'Test User',
321+
'last_name' => '',
322+
'status' => 'active',
323+
'unsubscribe_token' => 'test_token_123',
324+
);
325+
} );
302326

303-
// Mock subscriber creation (insert into subscribers table).
304-
$subscriber_insert_id = 100;
327+
// Mock subscriber creation (insert into subscribers table by get_or_create).
305328
$wpdb->shouldReceive( 'insert' )
306329
->once()
307330
->with(
@@ -314,18 +337,6 @@ public function test_one_time_email_logs_failure(): void {
314337
return 1;
315338
} );
316339

317-
// Mock get_by_id after creation.
318-
$wpdb->shouldReceive( 'get_row' )
319-
->once()
320-
->andReturn( (object) array(
321-
'id' => $subscriber_insert_id,
322-
'email' => 'user@example.com',
323-
'first_name' => 'Test User',
324-
'last_name' => '',
325-
'status' => 'active',
326-
'unsubscribe_token' => 'test_token_123',
327-
) );
328-
329340
// Expect database insert for campaigns table.
330341
$wpdb->shouldReceive( 'insert' )
331342
->once()
@@ -409,13 +420,31 @@ public function test_placeholder_replacement_in_one_time_email(): void {
409420
$sent_subject = '';
410421
$sent_body = '';
411422

412-
// Mock subscriber lookup (get_by_email returns null - subscriber doesn't exist).
423+
// Mock all get_row calls:
424+
// 1. replace_one_time_placeholders() checks if recipient is subscriber → null (not found)
425+
// 2. subscriber_service->get_by_email() in get_or_create() → null (not found)
426+
// 3. subscriber_service->get_by_id() after creating subscriber → subscriber object
427+
$call_count = 0;
428+
$subscriber_insert_id = 100;
413429
$wpdb->shouldReceive( 'get_row' )
414-
->once()
415-
->andReturn( null );
430+
->andReturnUsing( function () use ( &$call_count, $subscriber_insert_id ) {
431+
++$call_count;
432+
// First two calls return null (subscriber not found).
433+
if ( $call_count <= 2 ) {
434+
return null;
435+
}
436+
// Third call returns the newly created subscriber.
437+
return (object) array(
438+
'id' => $subscriber_insert_id,
439+
'email' => 'john@example.com',
440+
'first_name' => 'John Doe',
441+
'last_name' => '',
442+
'status' => 'active',
443+
'unsubscribe_token' => 'test_token_123',
444+
);
445+
} );
416446

417-
// Mock subscriber creation (insert into subscribers table).
418-
$subscriber_insert_id = 100;
447+
// Mock subscriber creation (insert into subscribers table by get_or_create).
419448
$wpdb->shouldReceive( 'insert' )
420449
->once()
421450
->with(
@@ -428,18 +457,6 @@ public function test_placeholder_replacement_in_one_time_email(): void {
428457
return 1;
429458
} );
430459

431-
// Mock get_by_id after creation.
432-
$wpdb->shouldReceive( 'get_row' )
433-
->once()
434-
->andReturn( (object) array(
435-
'id' => $subscriber_insert_id,
436-
'email' => 'john@example.com',
437-
'first_name' => 'John Doe',
438-
'last_name' => '',
439-
'status' => 'active',
440-
'unsubscribe_token' => 'test_token_123',
441-
) );
442-
443460
// First insert is to campaigns table.
444461
$wpdb->shouldReceive( 'insert' )
445462
->once()

0 commit comments

Comments
 (0)