diff --git a/database/migrations/6_transform_logs_cc_bcc_array.php b/database/migrations/6_transform_logs_cc_bcc_array.php new file mode 100644 index 0000000..644cd93 --- /dev/null +++ b/database/migrations/6_transform_logs_cc_bcc_array.php @@ -0,0 +1,45 @@ +getTable(); + + DB::table($tableName) + ->whereNotNull('cc') + ->orWhereNotNull('bcc') + ->orderBy('id') + ->each(function (object $log) use ($tableName) { + $changes = []; + + if (!is_null($log->cc)) { + $changes['cc'] = [json_decode($log->cc, true)]; + } + + if (!is_null($log->bcc)) { + $changes['bcc'] = [json_decode($log->bcc, true)]; + } + + if (!empty($changes)) { + DB::table($tableName) + ->where('id', $log->id) + ->update($changes); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7547831..3bb11a2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,14 +1,11 @@ - + tests - - ./src - @@ -22,4 +19,9 @@ + + + ./src + + diff --git a/resources/views/modals/details.blade.php b/resources/views/modals/details.blade.php index 9c9419a..d496bb0 100644 --- a/resources/views/modals/details.blade.php +++ b/resources/views/modals/details.blade.php @@ -10,22 +10,26 @@ @if ($log->cc)

Cc

- @if ($log->cc->name) -

{{ $log->cc->name }} <{{ $log->cc->email }}>

- @else -

{{ $log->cc->email }}

- @endif + @foreach ($log->cc as $cc) + @if ($cc->name) +

{{ $cc->name }} <{{ $cc->email }}>

+ @else +

{{ $cc->email }}

+ @endif + @endforeach
@endif @if ($log->bcc)

Bcc

- @if ($log->bcc->name) -

{{ $log->bcc->name }} <{{ $log->bcc->email }}>

- @else -

{{ $log->bcc->email }}

- @endif + @foreach ($log->bcc as $bcc) + @if ($bcc->name) +

{{ $bcc->name }} <{{ $bcc->email }}>

+ @else +

{{ $bcc->email }}

+ @endif + @endforeach
@endif diff --git a/src/Dto/Casters/ContactArrayCaster.php b/src/Dto/Casters/ContactArrayCaster.php new file mode 100644 index 0000000..1cccf7a --- /dev/null +++ b/src/Dto/Casters/ContactArrayCaster.php @@ -0,0 +1,49 @@ + $value, + ]; + } + + if (Collection::make($value)->every(fn (mixed $value) => $value instanceof ContactDto)) { + return $value; + } + + if (is_array($value) && array_is_list($value) && is_string($value[0])) { + $value = array_map( + fn (string $item) => [ + 'email' => $item, + ], + $value + ); + } + + if (!array_is_list($value)) { + $value = [$value]; + } + + return array_map( + fn (array $item) => new ContactDto($item), + $value + ); + } +} diff --git a/src/Dto/GenericMailDto.php b/src/Dto/GenericMailDto.php index ada6805..e5883e3 100644 --- a/src/Dto/GenericMailDto.php +++ b/src/Dto/GenericMailDto.php @@ -21,9 +21,11 @@ class GenericMailDto extends DataTransferObject public ?ContactDto $sender; - public ?ContactDto $cc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + public ?array $cc; - public ?ContactDto $bcc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + public ?array $bcc; public Template $template; diff --git a/src/Dto/RecipientDto.php b/src/Dto/RecipientDto.php index 743ce01..8121b97 100644 --- a/src/Dto/RecipientDto.php +++ b/src/Dto/RecipientDto.php @@ -2,7 +2,7 @@ namespace MailCarrier\Dto; -use MailCarrier\Dto\Casters\ContactStringCaster; +use MailCarrier\Dto\Casters\ContactArrayCaster; use MailCarrier\Dto\Validators\Email; use Spatie\DataTransferObject\Attributes\CastWith; use Spatie\DataTransferObject\Casters\ArrayCaster; @@ -16,11 +16,13 @@ class RecipientDto extends DataTransferObject /** @var array */ public array $variables = []; - #[CastWith(ContactStringCaster::class)] - public ?ContactDto $cc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + #[CastWith(ContactArrayCaster::class)] + public ?array $cc; - #[CastWith(ContactStringCaster::class)] - public ?ContactDto $bcc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + #[CastWith(ContactArrayCaster::class)] + public ?array $bcc; /** @var \Illuminate\Http\UploadedFile[] */ public array $attachments = []; diff --git a/src/Dto/SendMailDto.php b/src/Dto/SendMailDto.php index daa8eb2..d4bc6f3 100644 --- a/src/Dto/SendMailDto.php +++ b/src/Dto/SendMailDto.php @@ -2,6 +2,7 @@ namespace MailCarrier\Dto; +use MailCarrier\Dto\Casters\ContactArrayCaster; use MailCarrier\Dto\Casters\ContactStringCaster; use Spatie\DataTransferObject\Attributes\CastWith; use Spatie\DataTransferObject\Casters\ArrayCaster; @@ -20,11 +21,13 @@ class SendMailDto extends DataTransferObject public ?string $recipient; - #[CastWith(ContactStringCaster::class)] - public ?ContactDto $cc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + #[CastWith(ContactArrayCaster::class)] + public ?array $cc; - #[CastWith(ContactStringCaster::class)] - public ?ContactDto $bcc; + /** @var \MailCarrier\Dto\ContactDto[]|null */ + #[CastWith(ContactArrayCaster::class)] + public ?array $bcc; /** @var array */ public array $variables = []; diff --git a/src/Http/Requests/SendMailRequest.php b/src/Http/Requests/SendMailRequest.php index b81f28b..36ce4cf 100644 --- a/src/Http/Requests/SendMailRequest.php +++ b/src/Http/Requests/SendMailRequest.php @@ -26,8 +26,10 @@ public function rules(): array 'trigger' => 'sometimes|string|max:255', 'subject' => 'required|string|max:255', 'sender' => ['sometimes', new ContactRule()], - 'cc' => ['sometimes', new ContactRule()], - 'bcc' => ['sometimes', new ContactRule()], + 'cc' => 'sometimes|array', + 'bcc' => 'sometimes|array', + 'cc.*' => [new ContactRule()], + 'bcc.*' => [new ContactRule()], 'variables' => 'sometimes|array', 'tags' => 'sometimes|array', 'metadata' => 'sometimes|array', @@ -65,8 +67,10 @@ public function rules(): array Rule::when($this->has('recipients') && is_array($this->json('recipients.0')), 'required|email'), ], 'recipients.*.variables' => 'sometimes|array', - 'recipients.*.cc' => ['sometimes', new ContactRule()], - 'recipients.*.bcc' => ['sometimes', new ContactRule()], + 'recipients.*.cc' => 'sometimes|array', + 'recipients.*.cc.*' => [new ContactRule()], + 'recipients.*.bcc' => 'sometimes|array', + 'recipients.*.bcc.*' => [new ContactRule()], // Recipients attachments 'recipients.*.attachments' => 'sometimes|array', @@ -106,6 +110,26 @@ protected function prepareForValidation(): void ]); } + // Wrap cc array list + if ( + !is_null($this->input('cc')) + && (!is_array($this->input('cc')) || !array_is_list($this->input('cc'))) + ) { + $this->merge([ + 'cc' => [$this->input('cc')], + ]); + } + + // Wrap bcc array list + if ( + !is_null($this->input('bcc')) + && (!is_array($this->input('bcc')) || !array_is_list($this->input('bcc'))) + ) { + $this->merge([ + 'bcc' => [$this->input('bcc')], + ]); + } + // Wrap recipient array list into a structured data if (is_array($this->input('recipients')) && !is_array($this->json('recipients.0'))) { $this->merge([ @@ -118,6 +142,31 @@ protected function prepareForValidation(): void ]); } + // Wrap recipient cc and bcc + $recipients = $this->input('recipients'); + + if (is_array($recipients)) { + foreach ($recipients as $i => $recipient) { + if ( + array_key_exists('cc', $recipient) + && (!is_array($this->input('cc')) || !array_is_list($this->input('cc'))) + ) { + $recipients[$i]['cc'] = [$recipient['cc']]; + } + + if ( + array_key_exists('bcc', $recipient) + && (!is_array($this->input('bcc')) || !array_is_list($this->input('bcc'))) + ) { + $recipients[$i]['bcc'] = [$recipient['bcc']]; + } + } + + $this->merge([ + 'recipients' => $recipients, + ]); + } + // Wrap remote attachments array list into a structured data if (is_array($this->input('remoteAttachments')) && !is_array($this->json('remoteAttachments.0'))) { $this->merge([ diff --git a/src/Mail/GenericMail.php b/src/Mail/GenericMail.php index 0eedb7d..57859ef 100644 --- a/src/Mail/GenericMail.php +++ b/src/Mail/GenericMail.php @@ -65,11 +65,11 @@ public function build(): self ) ->when( $this->params->cc, - fn (GenericMail $mail) => $mail->cc($this->params->cc->email, $this->params->cc->name) + fn (GenericMail $mail) => $mail->cc($this->params->cc) ) ->when( $this->params->bcc, - fn (GenericMail $mail) => $mail->bcc($this->params->bcc->email, $this->params->bcc->name) + fn (GenericMail $mail) => $mail->bcc($this->params->bcc) ); } diff --git a/src/MailCarrierServiceProvider.php b/src/MailCarrierServiceProvider.php index 81b3ebe..517608e 100644 --- a/src/MailCarrierServiceProvider.php +++ b/src/MailCarrierServiceProvider.php @@ -76,6 +76,7 @@ public function packageConfigured(Package $package): void '3_create_templates_table', '4_create_logs_table', '5_create_attachments_table', + '6_transform_logs_cc_bcc_array', ]) ->runsMigrations(); diff --git a/src/Models/Casts/CollectionOfContacts.php b/src/Models/Casts/CollectionOfContacts.php new file mode 100644 index 0000000..4fca1b1 --- /dev/null +++ b/src/Models/Casts/CollectionOfContacts.php @@ -0,0 +1,37 @@ + $attributes + * @return \Illuminate\Support\Collection<\MailCarrier\Dto\ContactDto>|null + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection + { + if (is_null($value)) { + return null; + } + + return Collection::make(json_decode($value, true)) + ->map(fn (array $value) => new ContactDto($value)); + } + + /** + * Prepare the given value for storage. + * + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + return json_encode($value); + } +} diff --git a/src/Models/Log.php b/src/Models/Log.php index 1a78b34..588e602 100644 --- a/src/Models/Log.php +++ b/src/Models/Log.php @@ -13,6 +13,7 @@ use MailCarrier\Dto\ContactDto; use MailCarrier\Dto\LogTemplateDto; use MailCarrier\Enums\LogStatus; +use MailCarrier\Models\Casts\CollectionOfContacts; use MailCarrier\Models\Concerns\IsUuid; /** @@ -21,8 +22,8 @@ * @property string|null $trigger * @property string|null $subject * @property \MailCarrier\Dto\ContactDto $sender - * @property \MailCarrier\Dto\ContactDto $cc - * @property \MailCarrier\Dto\ContactDto $bcc + * @property \Illuminate\Support\Collection|null $cc + * @property \Illuminate\Support\Collection|null $bcc * @property string $recipient * @property \MailCarrier\Dto\LogTemplateDto $template_frozen * @property array|null $variables @@ -82,8 +83,8 @@ class Log extends Model protected $casts = [ 'status' => LogStatus::class, 'sender' => ContactDto::class, - 'cc' => ContactDto::class, - 'bcc' => ContactDto::class, + 'cc' => CollectionOfContacts::class, + 'bcc' => CollectionOfContacts::class, 'template_frozen' => LogTemplateDto::class, 'variables' => 'array', ]; diff --git a/tests/Feature/Logs/CreateFromGenericMailTest.php b/tests/Feature/Logs/CreateFromGenericMailTest.php index 942c014..e1f32cc 100644 --- a/tests/Feature/Logs/CreateFromGenericMailTest.php +++ b/tests/Feature/Logs/CreateFromGenericMailTest.php @@ -29,12 +29,19 @@ sender: new ContactDto( email: 'sender@example.org', ), - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + new ContactDto( + email: 'bcc2@example.org', + ), + ], template: $template, variables: ['name' => 'foo'], )); @@ -42,8 +49,9 @@ expect($log->trigger)->toBe('test'); expect($log->subject)->toBe('Welcome'); expect($log->sender->email)->toBe('sender@example.org'); - expect($log->cc->email)->toBe('cc@example.org'); - expect($log->bcc->email)->toBe('bcc@example.org'); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); + expect($log->bcc[1]->email)->toBe('bcc2@example.org'); expect($log->variables)->tobe(['name' => 'foo']); expect($log->template->id)->toBe($template->id); expect($log->status)->toBe($expected); @@ -68,12 +76,19 @@ subject: 'Welcome', error: null, recipient: 'foo@example.org', - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + new ContactDto( + email: 'cc2@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + ], template: $template, variables: ['name' => 'foo'], ); @@ -86,8 +101,9 @@ expect($log->subject)->toBe('Welcome'); expect($log->sender->name)->toBe('MailCarrier'); expect($log->sender->email)->toBe('no-reply@mailcarrier.app'); - expect($log->cc->email)->toBe('cc@example.org'); - expect($log->bcc->email)->toBe('bcc@example.org'); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->cc[1]->email)->toBe('cc2@example.org'); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); expect($log->variables)->tobe(['name' => 'foo']); expect($log->template->id)->toBe($template->id); expect($log->status)->toBe(LogStatus::Pending); @@ -110,12 +126,19 @@ sender: new ContactDto( email: 'sender@example.org', ), - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + new ContactDto( + email: 'bcc2@example.org', + ), + ], template: $template, variables: ['name' => 'foo'], )); @@ -123,8 +146,9 @@ expect($log->trigger)->toBe('test'); expect($log->subject)->toBe('Welcome'); expect($log->sender->email)->toBe('sender@example.org'); - expect($log->cc->email)->toBe('cc@example.org'); - expect($log->bcc->email)->toBe('bcc@example.org'); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); + expect($log->bcc[1]->email)->toBe('bcc2@example.org'); expect($log->variables)->tobe(['name' => 'foo']); expect($log->template->id)->toBe($template->id); expect($log->template_frozen->name)->toBe('template'); @@ -148,12 +172,19 @@ sender: new ContactDto( email: 'sender@example.org', ), - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + new ContactDto( + email: 'cc2@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + ], template: Template::factory()->create(), variables: ['name' => 'foo'], attachments: [ @@ -243,12 +274,19 @@ sender: new ContactDto( email: 'sender@example.org', ), - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + new ContactDto( + email: 'bcc2@example.org', + ), + ], template: Template::factory()->create(), variables: ['name' => 'foo'], attachments: [ @@ -348,12 +386,19 @@ sender: new ContactDto( email: 'sender@example.org', ), - cc: new ContactDto( - email: 'cc@example.org', - ), - bcc: new ContactDto( - email: 'bcc@example.org', - ), + cc: [ + new ContactDto( + email: 'cc@example.org', + ), + new ContactDto( + email: 'cc2@example.org', + ), + ], + bcc: [ + new ContactDto( + email: 'bcc@example.org', + ), + ], template: Template::factory()->create(), variables: ['name' => 'foo'], attachments: [ diff --git a/tests/Feature/SendMailTest.php b/tests/Feature/SendMailTest.php index e733dc0..337746b 100644 --- a/tests/Feature/SendMailTest.php +++ b/tests/Feature/SendMailTest.php @@ -121,8 +121,96 @@ $log = Log::first(); expect($log->status)->toBe(LogStatus::Sent); - expect($log->cc->email)->toBe('cc@example.org'); - expect($log->bcc->email)->toBe('bcc@example.org'); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); + expect($log->error)->toBeNull(); +}); + +it('allows multiple cc and bcc as email strings', function () { + Template::factory()->create([ + 'slug' => 'welcome', + ]); + + postJson(route('mailcarrier.send'), [ + 'enqueue' => false, + 'template' => 'welcome', + 'subject' => 'Welcome!', + 'recipient' => 'recipient@example.org', + 'cc' => ['cc@example.org', 'cc2@example.org'], + 'bcc' => ['bcc@example.org', 'bcc2@example.org'], + ])->assertOk(); + + Mail::assertSent(GenericMail::class, 1); + + Mail::assertSent(GenericMail::class, function (GenericMail $mail) { + $mail->build(); + + return $mail->hasTo('recipient@example.org') + && $mail->hasSubject('Welcome!') + && $mail->hasCc('cc@example.org') + && $mail->hasCc('cc2@example.org') + && $mail->hasBcc('bcc@example.org') + && $mail->hasBcc('bcc2@example.org'); + }); + + /** @var Log */ + $log = Log::first(); + + expect($log->status)->toBe(LogStatus::Sent); + expect($log->cc)->toHaveCount(2); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->cc[1]->email)->toBe('cc2@example.org'); + expect($log->bcc)->toHaveCount(2); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); + expect($log->bcc[1]->email)->toBe('bcc2@example.org'); + expect($log->error)->toBeNull(); +}); + +it('allows multiple cc and bcc as email contacts', function () { + Template::factory()->create([ + 'slug' => 'welcome', + ]); + + postJson(route('mailcarrier.send'), [ + 'enqueue' => false, + 'template' => 'welcome', + 'subject' => 'Welcome!', + 'recipient' => 'recipient@example.org', + 'cc' => [ + ['email' => 'cc@example.org', 'name' => 'CC'], + ['email' => 'cc2@example.org'], + ], + 'bcc' => [ + ['email' => 'bcc@example.org', 'name' => 'BCC'], + ['email' => 'bcc2@example.org'], + ], + ])->assertOk(); + + Mail::assertSent(GenericMail::class, 1); + + Mail::assertSent(GenericMail::class, function (GenericMail $mail) { + $mail->build(); + + return $mail->hasTo('recipient@example.org') + && $mail->hasSubject('Welcome!') + && $mail->hasCc('cc@example.org', 'CC') + && $mail->hasCc('cc2@example.org') + && $mail->hasBcc('bcc@example.org', 'BCC') + && $mail->hasBcc('bcc2@example.org'); + }); + + /** @var Log */ + $log = Log::first(); + + expect($log->status)->toBe(LogStatus::Sent); + expect($log->cc)->toHaveCount(2); + expect($log->cc[0]->email)->toBe('cc@example.org'); + expect($log->cc[0]->name)->toBe('CC'); + expect($log->cc[1]->email)->toBe('cc2@example.org'); + expect($log->bcc)->toHaveCount(2); + expect($log->bcc[0]->email)->toBe('bcc@example.org'); + expect($log->bcc[0]->name)->toBe('BCC'); + expect($log->bcc[1]->email)->toBe('bcc2@example.org'); expect($log->error)->toBeNull(); }); @@ -451,16 +539,16 @@ expect($log1->status)->toBe(LogStatus::Sent); expect($log1->error)->toBeNull(); - expect($log1->cc->email)->toBe('recipient1+cc@example.org'); - expect($log1->bcc->email)->toBe('recipient1+bcc@example.org'); + expect($log1->cc[0]->email)->toBe('recipient1+cc@example.org'); + expect($log1->bcc[0]->email)->toBe('recipient1+bcc@example.org'); expect($log1->variables)->toEqualCanonicalizing([ 'name' => 'foo', ]); expect($log2->status)->toBe(LogStatus::Sent); expect($log2->error)->toBeNull(); - expect($log2->cc->email)->toBe('recipient2+cc@example.org'); - expect($log2->bcc->email)->toBe('recipient2+bcc@example.org'); + expect($log2->cc[0]->email)->toBe('recipient2+cc@example.org'); + expect($log2->bcc[0]->email)->toBe('recipient2+bcc@example.org'); expect($log2->variables)->toEqualCanonicalizing([ 'name' => 'bar', ]);