diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c6e254..a2622b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [0.0.6] 2025-08-26 + +* Fix - Update Email task to properly handle multiple email recipients separated by commas. + +[0.0.6]: https://github.com/stellarwp/shepherd/releases/tag/0.0.6 + ## [0.0.5] 2025-08-19 * Fix - Ensure the AS logger table exists before using it. Introduce a filter `shepherd__should_log` to disable logging. diff --git a/README.md b/README.md index 18404bee..f2a5a335 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Shepherd is a lightweight and powerful background processing library for WordPre - **Automatic Retries**: Configurable automatic retries for failed tasks. - **Debouncing**: Prevent tasks from running too frequently. - **Logging**: Built-in database logging for task lifecycle events. -- **Included Tasks**: Comes with a ready-to-use `Email` task. +- **Included Tasks**: Comes with ready-to-use tasks including `Email` (with multi-recipient support), `HTTP_Request`, and `Herding` tasks. ## Getting Started diff --git a/docs/tasks.md b/docs/tasks.md index cae1cf72..73b02e7f 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -12,6 +12,7 @@ Sends emails asynchronously using WordPress's `wp_mail()` function. - Automatic retries (up to 4 additional attempts) - Support for HTML content and attachments +- Support for multiple recipients (comma-separated) - Comprehensive error handling - WordPress action hooks for tracking @@ -20,6 +21,7 @@ Sends emails asynchronously using WordPress's `wp_mail()` function. ```php use StellarWP\Shepherd\Tasks\Email; +// Single recipient $email = new Email( 'user@example.com', 'Welcome!', @@ -28,6 +30,15 @@ $email = new Email( ); shepherd()->dispatch( $email ); + +// Multiple recipients +$team_email = new Email( + 'user1@example.com, user2@example.com, admin@example.com', + 'Team Update', + 'Important announcement for the team' +); + +shepherd()->dispatch( $team_email ); ``` ### [HTTP Request Task](./tasks/http-request.md) diff --git a/docs/tasks/email.md b/docs/tasks/email.md index b8b4a85d..79415094 100644 --- a/docs/tasks/email.md +++ b/docs/tasks/email.md @@ -16,7 +16,7 @@ public function __construct( ### Parameters -- **`$to_email`** (string, required): Recipient's email address +- **`$to_email`** (string, required): Recipient's email address(es). Can be a single email or multiple comma-separated emails. - **`$subject`** (string, required): Email subject line - **`$body`** (string, required): Email body content (HTML or plain text) - **`$headers`** (array, optional): Email headers (e.g., content type, reply-to) @@ -65,6 +65,29 @@ $email = new Email( shepherd()->dispatch( $email ); ``` +### Email to Multiple Recipients + +```php +// Send to multiple recipients +$email = new Email( + 'user1@example.com, user2@example.com, admin@example.com', + 'Team Update', + 'Important update for all team members.' +); + +shepherd()->dispatch( $email ); + +// With proper spacing (whitespace is automatically handled) +$email = new Email( + 'user1@example.com,user2@example.com, user3@example.com', + 'Newsletter', + '

Weekly Newsletter

Here are this week\'s updates...

', + [ 'Content-Type: text/html; charset=UTF-8' ] +); + +shepherd()->dispatch( $email ); +``` + ### Email with Attachments ```php diff --git a/shepherd.php b/shepherd.php index eeff43f2..90a82f98 100644 --- a/shepherd.php +++ b/shepherd.php @@ -9,7 +9,7 @@ * @wordpress-plugin * Plugin Name: Shepherd * Description: A library for offloading tasks to background processes. - * Version: 0.0.5 + * Version: 0.0.6 * Author: StellarWP * Author URI: https://stellarwp.com * License: GPL-2.0-or-later diff --git a/src/Tasks/Email.php b/src/Tasks/Email.php index 6414b4fc..1dd0b396 100644 --- a/src/Tasks/Email.php +++ b/src/Tasks/Email.php @@ -30,8 +30,9 @@ class Email extends Task_Abstract { * The email task's constructor. * * @since 0.0.1 + * @since TBD - Allow multiple comma-separated recipients. * - * @param string $to_email The email address to send the email to. + * @param string $to_email The email address(es) to send the email to. Can be comma-separated for multiple recipients. * @param string $subject The email subject. * @param string $body The email body. * @param string[] $headers Optional. Additional headers. @@ -81,8 +82,23 @@ protected function validate_args(): void { throw new InvalidArgumentException( __( 'Email task requires at least 3 arguments.', 'stellarwp-shepherd' ) ); } - if ( ! is_email( $args[0] ) ) { - throw new InvalidArgumentException( __( 'Email address is invalid.', 'stellarwp-shepherd' ) ); + $recipients = $args[0]; + if ( ! is_string( $recipients ) || empty( trim( $recipients ) ) ) { + throw new InvalidArgumentException( __( 'Email recipients must be a non-empty string.', 'stellarwp-shepherd' ) ); + } + + // Split by comma and validate each email. + $emails = array_map( 'trim', explode( ',', $recipients ) ); + $invalid_emails = array_filter( $emails, fn( $email ) => ! is_email( $email ) ); + + if ( ! empty( $invalid_emails ) ) { + throw new InvalidArgumentException( + sprintf( + // translators: %s is a comma-separated list of invalid email addresses. + __( 'Invalid email address(es): %s', 'stellarwp-shepherd' ), + implode( ', ', $invalid_emails ) + ) + ); } if ( ! is_string( $args[1] ) ) { diff --git a/tests/integration/Tasks/Email_Test.php b/tests/integration/Tasks/Email_Test.php index 53f9075a..9895ff00 100644 --- a/tests/integration/Tasks/Email_Test.php +++ b/tests/integration/Tasks/Email_Test.php @@ -248,4 +248,62 @@ public function it_should_schedule_multiple_tasks_with_different_args(): void { $this->assertSame( [ 'test1@test.com', 'subject1', 'body1', [], [] ], $spy[0] ); $this->assertSame( [ 'test2@test.com', 'subject2', 'body2', [], [] ], $spy[1] ); } + + /** + * @test + */ + public function it_should_dispatch_and_process_email_with_multiple_recipients(): void { + $spy = []; + $this->set_fn_return( 'wp_mail', function ( ...$args ) use ( &$spy ) { + $spy[] = $args; + return true; + }, true ); + + $shepherd = shepherd(); + $this->assertNull( $shepherd->get_last_scheduled_task_id() ); + + $dummy_task = new Email( 'test1@test.com, test2@test.com, test3@test.com', 'subject', 'body', [ 'Reply-To: sender@test.com' ] ); + $shepherd->dispatch( $dummy_task ); + + $last_scheduled_task_id = $shepherd->get_last_scheduled_task_id(); + + $this->assertIsInt( $last_scheduled_task_id ); + + $this->assertTaskHasActionPending( $last_scheduled_task_id ); + $this->assertTaskIsScheduledForExecutionAt( $last_scheduled_task_id, time() ); + $this->assertTaskExecutesWithoutErrors( $last_scheduled_task_id ); + + $this->assertCount( 1, $spy ); + $this->assertSame( [ 'test1@test.com, test2@test.com, test3@test.com', 'subject', 'body', [ 'Reply-To: sender@test.com' ], [] ], $spy[0] ); + + $logs = $this->get_logger()->retrieve_logs( $last_scheduled_task_id ); + $this->assertCount( 3, $logs ); + $this->assertSame( 'created', $logs[0]->get_type() ); + $this->assertSame( 'started', $logs[1]->get_type() ); + $this->assertSame( 'finished', $logs[2]->get_type() ); + } + + /** + * @test + */ + public function it_should_handle_multiple_recipients_with_varying_whitespace(): void { + $spy = []; + $this->set_fn_return( 'wp_mail', function ( ...$args ) use ( &$spy ) { + $spy[] = $args; + return true; + }, true ); + + $shepherd = shepherd(); + + // Test with various whitespace patterns + $task = new Email( 'user1@test.com, user2@test.com ,user3@test.com', 'Test', 'Body' ); + $shepherd->dispatch( $task ); + $task_id = $shepherd->get_last_scheduled_task_id(); + + $this->assertTaskExecutesWithoutErrors( $task_id ); + + $this->assertCount( 1, $spy ); + // wp_mail receives the exact string we pass, WordPress handles the parsing + $this->assertSame( 'user1@test.com, user2@test.com ,user3@test.com', $spy[0][0] ); + } } diff --git a/tests/wpunit/Tasks/Email_Test.php b/tests/wpunit/Tasks/Email_Test.php index c3b904e6..4482e2a7 100644 --- a/tests/wpunit/Tasks/Email_Test.php +++ b/tests/wpunit/Tasks/Email_Test.php @@ -17,6 +17,7 @@ class Email_Test extends WPTestCase { */ public function it_should_throw_exception_for_invalid_email() { $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid email address(es): not-an-email' ); new Email( 'not-an-email', 'Subject', 'Body' ); } @@ -68,4 +69,82 @@ public function it_should_throw_shepherd_exception_if_wp_mail_fails() { $this->expectException( ShepherdTaskException::class ); $email->process(); } + + /** + * @test + */ + public function it_should_accept_multiple_recipients_separated_by_comma() { + $email = new Email( 'test1@test.com, test2@test.com, test3@test.com', 'Subject', 'Body' ); + + $spy = []; + $this->set_fn_return( 'wp_mail', function ( $to, $subject, $body, $headers = [], $attachments = [] ) use ( &$spy ) { + $spy = [ $to, $subject, $body, $headers, $attachments ]; + return true; + }, true ); + + $email->process(); + + $this->assertEquals( [ 'test1@test.com, test2@test.com, test3@test.com', 'Subject', 'Body', [], [] ], $spy ); + } + + /** + * @test + */ + public function it_should_accept_multiple_recipients_with_spaces() { + $email = new Email( 'test1@test.com, test2@test.com ,test3@test.com', 'Subject', 'Body' ); + + $spy = []; + $this->set_fn_return( 'wp_mail', function ( $to, $subject, $body, $headers = [], $attachments = [] ) use ( &$spy ) { + $spy = [ $to, $subject, $body, $headers, $attachments ]; + return true; + }, true ); + + $email->process(); + + $this->assertEquals( [ 'test1@test.com, test2@test.com ,test3@test.com', 'Subject', 'Body', [], [] ], $spy ); + } + + /** + * @test + */ + public function it_should_throw_exception_for_invalid_email_in_multiple_recipients() { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Invalid email address(es): not-an-email, another-invalid' ); + new Email( 'test@test.com, not-an-email, valid@email.com, another-invalid', 'Subject', 'Body' ); + } + + /** + * @test + */ + public function it_should_throw_exception_for_empty_string_recipients() { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Email recipients must be a non-empty string' ); + new Email( '', 'Subject', 'Body' ); + } + + /** + * @test + */ + public function it_should_throw_exception_for_whitespace_only_recipients() { + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Email recipients must be a non-empty string' ); + new Email( ' ', 'Subject', 'Body' ); + } + + /** + * @test + */ + public function it_should_accept_single_recipient() { + $email = new Email( 'test@test.com', 'Subject', 'Body' ); + + $spy = []; + $this->set_fn_return( 'wp_mail', function ( $to, $subject, $body, $headers = [], $attachments = [] ) use ( &$spy ) { + $spy = [ $to, $subject, $body, $headers, $attachments ]; + return true; + }, true ); + + $email->process(); + + $this->assertEquals( [ 'test@test.com', 'Subject', 'Body', [], [] ], $spy ); + } }