Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<hook_prefix>_should_log` to disable logging.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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!',
Expand All @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion docs/tasks/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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',
'<h1>Weekly Newsletter</h1><p>Here are this week\'s updates...</p>',
[ 'Content-Type: text/html; charset=UTF-8' ]
);

shepherd()->dispatch( $email );
```

### Email with Attachments

```php
Expand Down
2 changes: 1 addition & 1 deletion shepherd.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/Tasks/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] ) ) {
Expand Down
58 changes: 58 additions & 0 deletions tests/integration/Tasks/Email_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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] );
}
}
79 changes: 79 additions & 0 deletions tests/wpunit/Tasks/Email_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}

Expand Down Expand Up @@ -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 );
}
}
Loading