diff --git a/.gitignore b/.gitignore index 4218a0b..f11b2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ vendor/ .DS_Store composer.lock -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +.phpunit.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index c51e031..1a2583d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 6.2.1 - 2023-12-28 + +**Changed** + +- Test package with PHP 8.3 + ## 6.2.0 - 2023-04-09 **Added** diff --git a/README.md b/README.md index 7d08266..0dc8cdf 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,26 @@ Email::compose() ->bcc(['john@doe.com', 'jane@doe.com']); ``` +### Reply-To + +```php +replyTo(['john@doe.com', 'jane@doe.com']); + +Email::compose() + ->replyTo(new Address('john@doe.com', 'John Doe')); + +Email::compose() + ->replyTo([ + new Address('john@doe.com', 'John Doe'), + new Address('jane@doe.com', 'Jane Doe'), + ]); +``` + ### Using mailables You may also pass a mailable to the e-mail composer. @@ -232,3 +252,28 @@ To enable, set the following environment variable: ``` LARAVEL_DATABASE_EMAILS_SEND_IMMEDIATELY=true ``` + +### Pruning models + +```php +use Stackkit\LaravelDatabaseEmails\Email; + +$schedule->command('model:prune', [ + '--model' => [Email::class], +])->everyMinute(); +``` + +By default, e-mails are pruned when they are older than 6 months. + +You may change that by adding the following to the AppServiceProvider.php: + +```php +use Stackkit\LaravelDatabaseEmails\Email; + +public function register(): void +{ + Email::pruneWhen(function (Email $email) { + return $email->where(...); + }); +} +``` diff --git a/composer.json b/composer.json index c3f929b..1219696 100644 --- a/composer.json +++ b/composer.json @@ -54,6 +54,9 @@ "l6": [ "composer require laravel/framework:8.* orchestra/testbench:6.* --no-interaction --no-update", "composer update --prefer-stable --prefer-dist --no-interaction" + ], + "test": [ + "CI_DB_DRIVER=sqlite CI_DB_DATABASE=:memory: phpunit" ] } } diff --git a/database/migrations/2023_12_28_140000_add_reply_to_to_emails_table.php b/database/migrations/2023_12_28_140000_add_reply_to_to_emails_table.php new file mode 100644 index 0000000..09d4081 --- /dev/null +++ b/database/migrations/2023_12_28_140000_add_reply_to_to_emails_table.php @@ -0,0 +1,30 @@ +binary('reply_to')->nullable()->after('bcc'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} diff --git a/src/Email.php b/src/Email.php index 4eba5ff..f24831f 100644 --- a/src/Email.php +++ b/src/Email.php @@ -4,9 +4,12 @@ namespace Stackkit\LaravelDatabaseEmails; +use Closure; use Exception; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Prunable; /** * @property $id @@ -15,6 +18,7 @@ * @property $from * @property $cc * @property $bcc + * @property $reply_to * @property $subject * @property $view * @property $variables @@ -33,6 +37,7 @@ class Email extends Model { use HasEncryptedAttributes; + use Prunable; /** * The table in which the e-mails are stored. @@ -48,6 +53,8 @@ class Email extends Model */ protected $guarded = []; + public static ?Closure $pruneQuery = null; + /** * Compose a new e-mail. * @@ -190,6 +197,26 @@ public function getBccAttribute() return $this->bcc; } + /** + * Get the e-mail reply-to addresses. + * + * @return array|string + */ + public function getReplyTo() + { + return $this->reply_to; + } + + /** + * Get the e-mail reply-to addresses. + * + * @return array|string + */ + public function getReplyToAttribute() + { + return $this->reply_to; + } + /** * Get the e-mail subject. * @@ -388,6 +415,16 @@ public function hasBcc(): bool return strlen($this->getRawDatabaseValue('bcc')) > 0; } + /** + * Determine if the e-mail should sent with reply-to. + * + * @return bool + */ + public function hasReplyTo(): bool + { + return strlen($this->getRawDatabaseValue('reply_to') ?: '') > 0; + } + /** * Determine if the e-mail is scheduled to be sent later. * @@ -520,4 +557,25 @@ public function getRawDatabaseValue(string $key = null, $default = null) return $this->getOriginal($key, $default); } + + /** + * @param Closure $closure + * @return void + */ + public static function pruneWhen(Closure $closure) + { + static::$pruneQuery = $closure; + } + + /** + * @return Builder + */ + public function prunable() + { + if (static::$pruneQuery) { + return (static::$pruneQuery)($this); + } + + return $this->where('created_at', '<', now()->subMonths(6)); + } } diff --git a/src/EmailComposer.php b/src/EmailComposer.php index b358565..352e63e 100644 --- a/src/EmailComposer.php +++ b/src/EmailComposer.php @@ -139,6 +139,17 @@ public function bcc($bcc): self return $this->setData('bcc', $bcc); } + /** + * Define the reply-to address(es). + * + * @param string|array $replyTo + * @return self + */ + public function replyTo($replyTo): self + { + return $this->setData('reply_to', $replyTo); + } + /** * Set the e-mail subject. * diff --git a/src/Encrypter.php b/src/Encrypter.php index b61ef21..14052a7 100644 --- a/src/Encrypter.php +++ b/src/Encrypter.php @@ -17,6 +17,8 @@ public function encrypt(EmailComposer $composer): void $this->encryptRecipients($composer); + $this->encryptReplyTo($composer); + $this->encryptFrom($composer); $this->encryptSubject($composer); @@ -36,6 +38,20 @@ private function setEncrypted(EmailComposer $composer): void $composer->getEmail()->setAttribute('encrypted', 1); } + /** + * Encrypt the e-mail reply-to. + * + * @param EmailComposer $composer + */ + private function encryptReplyTo(EmailComposer $composer): void + { + $email = $composer->getEmail(); + + $email->fill([ + 'reply_to' => $composer->hasData('reply_to') ? encrypt($email->reply_to) : '', + ]); + } + /** * Encrypt the e-mail addresses of the recipients. * diff --git a/src/HasEncryptedAttributes.php b/src/HasEncryptedAttributes.php index bdc2ebf..01dfbb4 100644 --- a/src/HasEncryptedAttributes.php +++ b/src/HasEncryptedAttributes.php @@ -18,6 +18,7 @@ trait HasEncryptedAttributes 'from', 'cc', 'bcc', + 'reply_to', 'subject', 'variables', 'body', @@ -33,6 +34,7 @@ trait HasEncryptedAttributes 'from', 'cc', 'bcc', + 'reply_to', 'variables', ]; diff --git a/src/MailableReader.php b/src/MailableReader.php index bbd9b83..86be654 100644 --- a/src/MailableReader.php +++ b/src/MailableReader.php @@ -34,6 +34,8 @@ public function read(EmailComposer $composer): void $this->readBcc($composer); + $this->readReplyTo($composer); + $this->readSubject($composer); $this->readBody($composer); @@ -115,6 +117,20 @@ private function readBcc(EmailComposer $composer): void $composer->bcc($bcc); } + /** + * Read the mailable reply-to to the email composer. + * + * @param EmailComposer $composer + */ + private function readReplyTo(EmailComposer $composer): void + { + $replyTo = $this->convertMailableAddresses( + $composer->getData('mailable')->replyTo + ); + + $composer->replyTo($replyTo); + } + /** * Read the mailable subject to the email composer. * @@ -137,7 +153,7 @@ private function readBody(EmailComposer $composer): void $mailable = $composer->getData('mailable'); - $composer->setData('body', view($mailable->view, $mailable->buildViewData())); + $composer->setData('body', view($mailable->view, $mailable->buildViewData())->render()); } /** diff --git a/src/Preparer.php b/src/Preparer.php index 831e0c0..ea405d0 100644 --- a/src/Preparer.php +++ b/src/Preparer.php @@ -5,6 +5,7 @@ namespace Stackkit\LaravelDatabaseEmails; use Carbon\Carbon; +use Illuminate\Mail\Mailables\Address; class Preparer { @@ -25,6 +26,8 @@ public function prepare(EmailComposer $composer): void $this->prepareBcc($composer); + $this->prepareReplyTo($composer); + $this->prepareSubject($composer); $this->prepareView($composer); @@ -118,6 +121,33 @@ private function prepareBcc(EmailComposer $composer): void ]); } + /** + * Prepare the reply-to for database storage. + * + * @param EmailComposer $composer + */ + private function prepareReplyTo(EmailComposer $composer): void + { + $value = $composer->getData('reply_to', []); + + if (! is_array($value)) { + $value = [$value]; + } + + foreach ($value as $i => $v) { + if ($v instanceof Address) { + $value[$i] = [ + 'address' => $v->address, + 'name' => $v->name, + ]; + } + } + + $composer->getEmail()->fill([ + 'reply_to' => json_encode($value), + ]); + } + /** * Prepare the subject for database storage. * diff --git a/src/Sender.php b/src/Sender.php index 2a9bf63..60a80fe 100644 --- a/src/Sender.php +++ b/src/Sender.php @@ -46,6 +46,7 @@ private function buildMessage(Message $message, Email $email): void $message->to($email->getRecipient()) ->cc($email->hasCc() ? $email->getCc() : []) ->bcc($email->hasBcc() ? $email->getBcc() : []) + ->replyTo($email->hasReplyTo() ? $email->getReplyTo() : []) ->subject($email->getSubject()) ->from($email->getFromAddress(), $email->getFromName()); diff --git a/src/SentMessage.php b/src/SentMessage.php index 98c6156..570d524 100644 --- a/src/SentMessage.php +++ b/src/SentMessage.php @@ -13,6 +13,7 @@ class SentMessage public $to = []; public $cc = []; public $bcc = []; + public $replyTo = []; public $subject = ''; public $body = ''; public $attachments = []; @@ -38,6 +39,10 @@ public static function createFromSymfonyMailer(\Symfony\Component\Mime\Email $em $sentMessage->bcc[$address->getAddress()] = $address->getName(); } + foreach ($email->getReplyTo() as $address) { + $sentMessage->replyTo[$address->getAddress()] = $address->getName(); + } + $sentMessage->subject = $email->getSubject(); $sentMessage->body = $email->getHtmlBody(); $sentMessage->attachments = array_map(function (DataPart $dataPart) { @@ -58,6 +63,7 @@ public static function createFromSwiftMailer(\Swift_Mime_SimpleMessage $message) $sentMessage->to = $message->getTo(); $sentMessage->cc = $message->getCc(); $sentMessage->bcc = $message->getBcc(); + $sentMessage->replyTo = $message->getReplyTo(); $sentMessage->subject = $message->getSubject(); $sentMessage->body = $message->getBody(); $sentMessage->attachments = array_map(function(Swift_Mime_SimpleMimeEntity $entity) { diff --git a/src/Validator.php b/src/Validator.php index 038c484..69d130f 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -6,7 +6,10 @@ use Exception; use Carbon\Carbon; +use Illuminate\Mail\Mailables\Address; +use Illuminate\Support\Arr; use InvalidArgumentException; +use const FILTER_VALIDATE_EMAIL; class Validator { @@ -33,6 +36,8 @@ public function validate(EmailComposer $composer): void $this->validateBcc($composer); + $this->validateReplyTo($composer); + $this->validateSubject($composer); $this->validateView($composer); @@ -118,6 +123,29 @@ private function validateBcc(EmailComposer $composer): void } } + /** + * Validate the reply-to addresses. + * + * @param EmailComposer $composer + * @throws InvalidArgumentException + */ + private function validateReplyTo(EmailComposer $composer): void + { + if (! $composer->hasData('reply_to')) { + return; + } + + foreach (Arr::wrap($composer->getData('reply_to')) as $replyTo) { + if ($replyTo instanceof Address) { + $replyTo = $replyTo->address; + } + + if (! filter_var($replyTo, FILTER_VALIDATE_EMAIL)) { + throw new InvalidargumentException('E-mail address [' . $replyTo . '] is invalid'); + } + } + } + /** * Validate the e-mail subject. * diff --git a/tests/DatabaseInteractionTest.php b/tests/DatabaseInteractionTest.php index 54dfdbf..1bc8d71 100644 --- a/tests/DatabaseInteractionTest.php +++ b/tests/DatabaseInteractionTest.php @@ -44,6 +44,20 @@ public function cc_and_bcc_should_be_saved_correctly() $this->assertEquals(['jane@doe.com'], $email->getBcc()); } + /** @test */ + public function reply_to_should_be_saved_correctly() + { + $email = $this->sendEmail([ + 'reply_to' => $replyTo = [ + 'john@doe.com', + ], + ]); + + $this->assertEquals(json_encode($replyTo), DB::table('emails')->find(1)->reply_to); + $this->assertTrue($email->hasReplyTo()); + $this->assertEquals(['john@doe.com'], $email->getReplyTo()); + } + /** @test */ public function subject_should_be_saved_correclty() { diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php index e3f6c01..36a9328 100644 --- a/tests/EncryptionTest.php +++ b/tests/EncryptionTest.php @@ -2,6 +2,8 @@ namespace Tests; +use Illuminate\Mail\Mailables\Address; + class EncryptionTest extends TestCase { public function setUp(): void @@ -46,6 +48,37 @@ public function cc_and_bb_should_be_encrypted_and_decrypted() $this->assertEquals($bcc, $email->getBcc()); } + /** @test */ + public function reply_to_should_be_encrypted_and_decrypted() + { + $email = $this->sendEmail([ + 'reply_to' => $replyTo = ['john+1@doe.com', 'john+2@doe.com'], + ]); + $this->assertEquals($replyTo, decrypt($email->getRawDatabaseValue('reply_to'))); + $this->assertEquals($replyTo, $email->getReplyTo()); + + if (! class_exists(Address::class)) { + return; + } + + // Test with a single Address object... + $email = $this->sendEmail([ + 'reply_to' => new Address('john+1@doe.com', 'John Doe'), + ]); + $this->assertEquals([['address' => 'john+1@doe.com', 'name' => 'John Doe']], decrypt($email->getRawDatabaseValue('reply_to'))); + $this->assertEquals([['address' => 'john+1@doe.com', 'name' => 'John Doe']], $email->getReplyTo()); + + // Address with an array of Address objects... + $email = $this->sendEmail([ + 'reply_to' => [ + new Address('john+1@doe.com', 'John Doe'), + new Address('jane+1@doe.com', 'Jane Doe'), + ], + ]); + $this->assertSame([['address' => 'john+1@doe.com', 'name' => 'John Doe'], ['address' => 'jane+1@doe.com', 'name' => 'Jane Doe']], decrypt($email->getRawDatabaseValue('reply_to'))); + $this->assertSame([['address' => 'john+1@doe.com', 'name' => 'John Doe'], ['address' => 'jane+1@doe.com', 'name' => 'Jane Doe']], $email->getReplyTo()); + } + /** @test */ public function the_subject_should_be_encrypted_and_decrypted() { diff --git a/tests/MailableReaderTest.php b/tests/MailableReaderTest.php index 8cba2b2..c8fbd11 100644 --- a/tests/MailableReaderTest.php +++ b/tests/MailableReaderTest.php @@ -54,6 +54,14 @@ public function it_extracts_bcc_addresses() $this->assertEquals(['john+bcc@doe.com', 'john+bcc2@doe.com'], $composer->getData('bcc')); } + /** @test */ + public function it_extracts_reply_to_addresses() + { + $composer = Email::compose()->mailable($this->mailable()); + + $this->assertEquals(['replyto@example.com', 'replyto2@example.com'], $composer->getData('reply_to')); + } + /** @test */ public function it_extracts_the_subject() { @@ -137,6 +145,7 @@ public function build() return $this->to('john@doe.com') ->cc(['john+cc@doe.com', 'john+cc2@doe.com']) ->bcc(['john+bcc@doe.com', 'john+bcc2@doe.com']) + ->replyTo(['replyto@example.com', 'replyto2@example.com']) ->subject('Your order has shipped!') ->attach(__DIR__ . '/files/pdf-sample.pdf', [ 'mime' => 'application/pdf', @@ -168,7 +177,7 @@ public function envelope(): Envelope ], ['john+cc@doe.com', 'john+cc2@doe.com'], ['john+bcc@doe.com', 'john+bcc2@doe.com'], - [], + ['replyto@example.com', new Address('replyto2@example.com')], 'Your order has shipped!' ); } diff --git a/tests/PruneTest.php b/tests/PruneTest.php new file mode 100644 index 0000000..03a51cc --- /dev/null +++ b/tests/PruneTest.php @@ -0,0 +1,49 @@ +sendEmail(); + + Carbon::setTestNow($email->created_at . ' + 6 months'); + $this->artisan('model:prune', ['--model' => [Email::class]]); + $this->assertInstanceOf(Email::class, $email->fresh()); + + Carbon::setTestNow($email->created_at . ' + 6 months + 1 day'); + + // Ensure the email object has to be passed manually, otherwise we are acidentally + // deleting everyone's e-mails... + $this->artisan('model:prune'); + $this->assertInstanceOf(Email::class, $email->fresh()); + + // Now test with it passed... then it should definitely be deleted. + $this->artisan('model:prune', ['--model' => [Email::class]]); + $this->assertNull($email->fresh()); + } + + /** @test */ + public function can_change_when_emails_are_pruned() + { + Email::pruneWhen(function (Email $email) { + return $email->where('created_at', '<', now()->subMonths(3)); + }); + + $email = $this->sendEmail(); + + Carbon::setTestNow($email->created_at . ' + 3 months'); + $this->artisan('model:prune', ['--model' => [Email::class]]); + $this->assertInstanceOf(Email::class, $email->fresh()); + + Carbon::setTestNow($email->created_at . ' + 3 months + 1 day'); + $this->artisan('model:prune', ['--model' => [Email::class]]); + $this->assertNull($email->fresh()); + } +} diff --git a/tests/SenderTest.php b/tests/SenderTest.php index 686bd50..eb86b66 100644 --- a/tests/SenderTest.php +++ b/tests/SenderTest.php @@ -2,6 +2,7 @@ namespace Tests; +use Illuminate\Mail\Mailables\Address; use Illuminate\Support\Facades\Event; use Stackkit\LaravelDatabaseEmails\MessageSent; use Stackkit\LaravelDatabaseEmails\SentMessage; @@ -225,4 +226,53 @@ public function emails_can_be_sent_immediately() $this->artisan('email:send'); $this->assertCount(1, $this->sent); } + + /** @test */ + public function it_adds_the_reply_to_addresses() + { + $this->sendEmail(['reply_to' => 'replyto@test.com']); + $this->artisan('email:send'); + $replyTo = reset($this->sent)->replyTo; + $this->assertCount(1, $replyTo); + $this->assertArrayHasKey('replyto@test.com', $replyTo); + + $this->sent = []; + $this->sendEmail(['reply_to' => ['replyto1@test.com', 'replyto2@test.com']]); + $this->artisan('email:send'); + $replyTo = reset($this->sent)->replyTo; + $this->assertCount(2, $replyTo); + $this->assertArrayHasKey('replyto1@test.com', $replyTo); + $this->assertArrayHasKey('replyto2@test.com', $replyTo); + + if (! class_exists(Address::class)) { + return; + } + + $this->sent = []; + $this->sendEmail([ + 'reply_to' => new Address('replyto@test.com', 'NoReplyTest'), + ]); + $this->artisan('email:send'); + $replyTo = reset($this->sent)->replyTo; + $this->assertCount(1, $replyTo); + $this->assertSame(['replyto@test.com' => 'NoReplyTest'], $replyTo); + + $this->sent = []; + $this->sendEmail([ + 'reply_to' => [ + new Address('replyto@test.com', 'NoReplyTest'), + new Address('replyto2@test.com', 'NoReplyTest2'), + ], + ]); + $this->artisan('email:send'); + $replyTo = reset($this->sent)->replyTo; + $this->assertCount(2, $replyTo); + $this->assertSame( + [ + 'replyto@test.com' => 'NoReplyTest', + 'replyto2@test.com' => 'NoReplyTest2', + ], + $replyTo + ); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 6223864..482aded 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -82,6 +82,7 @@ public function createEmail($overwrite = []) 'recipient' => 'john@doe.com', 'cc' => null, 'bcc' => null, + 'reply_to' => null, 'subject' => 'test', 'view' => 'tests::dummy', 'variables' => ['name' => 'John Doe'], @@ -92,6 +93,7 @@ public function createEmail($overwrite = []) ->recipient($params['recipient']) ->cc($params['cc']) ->bcc($params['bcc']) + ->replyTo($params['reply_to']) ->subject($params['subject']) ->view($params['view']) ->variables($params['variables']); diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index 20f86a0..b8b02ce 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -80,6 +80,21 @@ public function bcc_must_contain_valid_email_addresses() ->send(); } + /** @test */ + public function reply_to_must_contain_valid_email_addresses() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('E-mail address [not-a-valid-email-address] is invalid'); + + Email::compose() + ->recipient('john@doe.com') + ->replyTo([ + 'jane@doe.com', + 'not-a-valid-email-address', + ]) + ->send(); + } + /** @test */ public function a_subject_is_required() {