├── resources └── views │ └── .gitkeep ├── CHANGELOG.md ├── src ├── Actions │ ├── RelateMailToModels.php │ ├── ResendMail.php │ ├── RegisterWebhooks.php │ ├── SendHighBounceRateNotifications.php │ ├── AttachUuid.php │ └── LogMail.php ├── Contracts │ ├── MailProviderContract.php │ ├── HasAssociatedMails.php │ └── MailDriverContract.php ├── Enums │ ├── Provider.php │ └── EventType.php ├── Shared │ ├── AsAction.php │ └── Terminal.php ├── Listeners │ ├── LogSentMail.php │ ├── LogSendingMail.php │ ├── AttachMailLogUuid.php │ ├── UnsuppressEmailAddress.php │ ├── NotifyOnBounce.php │ ├── NotifyOnSpamComplaint.php │ ├── LogMailEvent.php │ └── StoreMailRelations.php ├── Exceptions │ └── LaravelMailException.php ├── Controllers │ └── WebhookController.php ├── Facades │ └── MailProvider.php ├── Events │ ├── MailLogged.php │ ├── MailResent.php │ ├── MailEvent.php │ ├── MailOpened.php │ ├── MailAccepted.php │ ├── MailClicked.php │ ├── MailDelivered.php │ ├── MailUnsuppressed.php │ ├── MailComplained.php │ ├── MailEventLogged.php │ ├── MailHardBounced.php │ ├── MailSoftBounced.php │ └── MailUnsubscribed.php ├── Commands │ ├── PruneMailCommand.php │ ├── WebhooksMailCommand.php │ ├── MonitorMailCommand.php │ ├── CheckBounceRateCommand.php │ └── ResendMailCommand.php ├── Managers │ └── MailProviderManager.php ├── Traits │ ├── HasMails.php │ ├── HasDynamicDrivers.php │ ├── SendsNotifications.php │ └── AssociatesModels.php ├── Jobs │ ├── ProcessWebhookJob.php │ └── ResendMailJob.php ├── Notifications │ ├── SpamComplaintNotification.php │ ├── BounceNotification.php │ └── HighBounceRateNotification.php ├── Models │ ├── MailAttachment.php │ ├── MailEvent.php │ └── Mail.php ├── Drivers │ ├── ResendDriver.php │ ├── MailDriver.php │ ├── MailgunDriver.php │ └── PostmarkDriver.php └── MailsServiceProvider.php ├── helpers.php ├── routes └── webhooks.php ├── database ├── factories │ ├── MailAttachmentFactory.php │ ├── MailEventFactory.php │ └── MailFactory.php └── migrations │ ├── 4_add_tags_to_mails_table.php.stub │ ├── 4_add_transport_column_to_mails_table.php.stub │ ├── 2_create_mailables_table.php.stub │ ├── 5_change_link_to_long_text.php.stub │ ├── 5_change_columns_to_long_text.php.stub │ ├── 2_create_mail_attachments_table.php.stub │ ├── 3_add_unsuppressed_at_to_mail_events.php.stub │ ├── 2_create_mail_events_table.php.stub │ └── 1_create_mails_table.php.stub ├── LICENSE.md ├── phpunit.xml.dist.bak ├── rector.php ├── composer.json ├── config └── mails.php └── README.md /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-mails` will be documented in this file. 4 | -------------------------------------------------------------------------------- /src/Actions/RelateMailToModels.php: -------------------------------------------------------------------------------- 1 | handle(...$parameters); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Listeners/LogSentMail.php: -------------------------------------------------------------------------------- 1 | prefix(config('mails.webhooks.routes.prefix')) 9 | ->group(function () { 10 | Route::post('{provider}', WebhookController::class)->name('mails.webhook'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/Controllers/WebhookController.php: -------------------------------------------------------------------------------- 1 | all()); 14 | 15 | return response('Event processed.', status: 202); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Facades/MailProvider.php: -------------------------------------------------------------------------------- 1 | '...', 16 | 'ip' => '', 17 | 'hostname' => '', 18 | 'payload' => '', 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /database/migrations/4_add_tags_to_mails_table.php.stub: -------------------------------------------------------------------------------- 1 | after('clicks', function (Blueprint $table) { 13 | $table->json('tags')->nullable(); 14 | }); 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /database/migrations/4_add_transport_column_to_mails_table.php.stub: -------------------------------------------------------------------------------- 1 | string('transport') 13 | ->nullable() 14 | ->after('mailer'); 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/Actions/RegisterWebhooks.php: -------------------------------------------------------------------------------- 1 | registerWebhooks( 17 | components: $factory 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Listeners/UnsuppressEmailAddress.php: -------------------------------------------------------------------------------- 1 | mailer) 13 | ->unsuppressEmailAddress( 14 | address: $mailUnsuppressed->emailAddress, 15 | stream_id: $mailUnsuppressed->stream_id ?? null 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /database/migrations/2_create_mailables_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignIdFor(config('mails.models.mail')) 14 | ->constrained() 15 | ->cascadeOnDelete(); 16 | $table->morphs('mailable'); 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/Listeners/NotifyOnBounce.php: -------------------------------------------------------------------------------- 1 | mailEvent->mail); 20 | 21 | $this->send($bounceNotification, $channels); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /database/factories/MailEventFactory.php: -------------------------------------------------------------------------------- 1 | 'delivered', 16 | 'payload' => [], 17 | ]; 18 | } 19 | 20 | public function bounce(): Factory 21 | { 22 | return $this->state(function () { 23 | return [ 24 | 'type' => 'hard_bounced', 25 | ]; 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Listeners/NotifyOnSpamComplaint.php: -------------------------------------------------------------------------------- 1 | mailEvent->mail); 20 | 21 | $this->send($spamComplaintNotification, $channels); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /database/migrations/5_change_link_to_long_text.php.stub: -------------------------------------------------------------------------------- 1 | longText('link')->nullable()->change(); 13 | }); 14 | 15 | } 16 | 17 | public function down() 18 | { 19 | Schema::table(config('mails.database.tables.events', 'mail_events'), function (Blueprint $table) { 20 | $table->string('link')->nullable()->change(); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /database/migrations/5_change_columns_to_long_text.php.stub: -------------------------------------------------------------------------------- 1 | longText('html')->nullable()->change(); 13 | $table->longText('text')->nullable()->change(); 14 | }); 15 | 16 | } 17 | 18 | public function down() 19 | { 20 | Schema::table(config('mails.database.tables.mails'), function (Blueprint $table) { 21 | $table->text('html')->nullable()->change(); 22 | $table->text('text')->nullable()->change(); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/Actions/SendHighBounceRateNotifications.php: -------------------------------------------------------------------------------- 1 | send($highBounceRateNotification, $channels); 26 | 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Shared/Terminal.php: -------------------------------------------------------------------------------- 1 | input = new StringInput($argInput); 25 | 26 | $this->outputSymfony = new ConsoleOutput; 27 | $this->outputStyle = new OutputStyle($this->input, $this->outputSymfony); 28 | 29 | $this->output = $this->outputStyle; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Commands/PruneMailCommand.php: -------------------------------------------------------------------------------- 1 | shouldPrune()) { 17 | $this->components->warn('Pruning has been disabled in the config'); 18 | 19 | return self::SUCCESS; 20 | } 21 | 22 | $this->call('model:prune', [ 23 | '--model' => [Mail::class], 24 | ]); 25 | 26 | $this->comment('All done'); 27 | 28 | return self::SUCCESS; 29 | } 30 | 31 | protected function shouldPrune(): bool 32 | { 33 | return config('mails.database.pruning.enabled', false); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/2_create_mail_attachments_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignIdFor(config('mails.models.mail')) 14 | ->constrained() 15 | ->cascadeOnDelete(); 16 | $table->string('disk'); 17 | $table->string('uuid'); 18 | $table->string('filename'); 19 | $table->string('mime'); 20 | $table->boolean('inline', false); 21 | $table->bigInteger('size'); 22 | $table->timestamps(); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/Managers/MailProviderManager.php: -------------------------------------------------------------------------------- 1 | driver($driver); 15 | } 16 | 17 | protected function createPostmarkDriver(): PostmarkDriver 18 | { 19 | return new PostmarkDriver; 20 | } 21 | 22 | protected function createMailgunDriver(): MailgunDriver 23 | { 24 | return new MailgunDriver; 25 | } 26 | 27 | protected function createResendDriver(): ResendDriver 28 | { 29 | return new ResendDriver; 30 | } 31 | 32 | public function getDefaultDriver(): ?string 33 | { 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/WebhooksMailCommand.php: -------------------------------------------------------------------------------- 1 | argument('provider'), 19 | components: $this->components 20 | ); 21 | 22 | return self::SUCCESS; 23 | } 24 | 25 | protected function promptForMissingArgumentsUsing(): array 26 | { 27 | return [ 28 | 'provider' => 'Which email provider would you like to register webhooks for?', 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/migrations/3_add_unsuppressed_at_to_mail_events.php.stub: -------------------------------------------------------------------------------- 1 | timestamp('unsuppressed_at') 16 | ->nullable() 17 | ->after('occurred_at'); 18 | }); 19 | 20 | Schema::table(config('mails.database.tables.mails', 'mails'), function (Blueprint $table): void { 21 | $table->string('mailer') 22 | ->after('uuid'); 23 | 24 | $table->string('stream_id') 25 | ->nullable() 26 | ->after('mailer'); 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/Traits/HasMails.php: -------------------------------------------------------------------------------- 1 | morphToMany(config('mails.models.mail'), 'mailable', 'mailables', 'mailable_id', 'mail_id'); 20 | } 21 | 22 | public function events(): HasManyThrough 23 | { 24 | return $this->hasManyThrough(config('mails.models.event'), config('mails.models.mail')); 25 | } 26 | 27 | /** 28 | * @param Mail|Mail[] $mail 29 | */ 30 | public function associateMail($mail): static 31 | { 32 | $this->mails()->syncWithoutDetaching($mail); 33 | 34 | return $this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Traits/HasDynamicDrivers.php: -------------------------------------------------------------------------------- 1 | drivers; 15 | } 16 | 17 | /** 18 | * @param string|string[] $drivers 19 | */ 20 | public function on($drivers, bool $merge = false): static 21 | { 22 | $drivers = array_wrap($drivers); 23 | 24 | if ($merge) { 25 | $drivers = array_merge($this->drivers, $drivers); 26 | } 27 | 28 | $via = [ 29 | 'discord' => DiscordChannel::class, 30 | 'mail' => MailChannel::class, 31 | ]; 32 | 33 | $drivers = array_map(fn ($driver): string => $via[$driver], $drivers); 34 | 35 | $this->drivers = $drivers; 36 | 37 | return $this; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Listeners/LogMailEvent.php: -------------------------------------------------------------------------------- 1 | provider)->getMailFromPayload($mailEvent->payload); 13 | 14 | if (! $mail) { 15 | return; 16 | } 17 | 18 | if (config('mails.webhooks.queue')) { 19 | $this->dispatch($mailEvent->provider, $mailEvent->payload); 20 | 21 | return; 22 | } 23 | 24 | $this->record($mailEvent->provider, $mailEvent->payload); 25 | } 26 | 27 | private function record(string $provider, array $payload): void 28 | { 29 | MailProvider::with($provider) 30 | ->logMailEvent($payload); 31 | } 32 | 33 | private function dispatch(string $provider, array $payload): void 34 | { 35 | dispatch(fn () => $this->record($provider, $payload)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Jobs/ProcessWebhookJob.php: -------------------------------------------------------------------------------- 1 | provider, array_column(Provider::cases(), 'value'))) { 26 | return; 27 | } 28 | 29 | if (! MailProvider::with($this->provider)->verifyWebhookSignature($this->payload)) { 30 | return; 31 | } 32 | 33 | MailEvent::dispatch($this->provider, $this->payload); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) vormkracht10 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Traits/SendsNotifications.php: -------------------------------------------------------------------------------- 1 | notify( 25 | $this->prepareNotification($notification, $channel) 26 | ); 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Prepare the notification for sending. 33 | */ 34 | protected function prepareNotification(Notification $notification, string $channel): Notification 35 | { 36 | if (method_exists($notification, 'on')) { 37 | return $notification->on($channel); 38 | } 39 | 40 | return $notification; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /database/migrations/2_create_mail_events_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignIdFor(config('mails.models.mail')) 14 | ->constrained() 15 | ->cascadeOnDelete(); 16 | $table->string('type'); 17 | $table->string('ip_address')->nullable(); 18 | $table->string('hostname')->nullable(); 19 | $table->string('platform')->nullable(); 20 | $table->string('os')->nullable(); 21 | $table->string('browser')->nullable(); 22 | $table->string('user_agent')->nullable(); 23 | $table->string('city')->nullable(); 24 | $table->char('country_code', 2)->nullable(); 25 | $table->string('link')->nullable(); 26 | $table->string('tag')->nullable(); 27 | $table->json('payload')->nullable(); 28 | $table->timestamps(); 29 | $table->timestamp('occurred_at')->nullable(); 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/Commands/MonitorMailCommand.php: -------------------------------------------------------------------------------- 1 | getBounceRate() >= $bounceRateTreshold) { 17 | // TODO: notify 18 | 19 | } 20 | 21 | if (null !== ($deliveryRateTreshold = config('mails.events.deliveryrate.treshold')) && $this->getDeliveryRate() <= $deliveryRateTreshold) { 22 | 23 | // TODO: notify 24 | 25 | } 26 | 27 | return self::SUCCESS; 28 | } 29 | 30 | public function getBounceRate(): float 31 | { 32 | $bounces = Mail::whereNotNull('soft_bounced_at')->orWhereNotNull('hard_bounced_at')->count(); 33 | $total = Mail::count(); 34 | 35 | return ($bounces / $total) * 100; 36 | } 37 | 38 | public function getDeliveryRate(): float 39 | { 40 | $deliveries = Mail::whereNotNull('delivered_at')->count(); 41 | $total = Mail::count(); 42 | 43 | return ($deliveries / $total) * 100; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Enums/EventType.php: -------------------------------------------------------------------------------- 1 | paths([ 14 | __DIR__.'/src', 15 | __DIR__.'/tests', 16 | ]); 17 | 18 | $rectorConfig->sets([ 19 | // PHP Level sets 20 | LevelSetList::UP_TO_PHP_82, 21 | 22 | // General code quality sets 23 | SetList::CODE_QUALITY, 24 | SetList::DEAD_CODE, 25 | SetList::EARLY_RETURN, 26 | SetList::TYPE_DECLARATION, 27 | SetList::PRIVATIZATION, 28 | SetList::NAMING, 29 | SetList::INSTANCEOF, 30 | SetList::STRICT_BOOLEANS, 31 | SetList::PHP_81, 32 | SetList::PHP_82, 33 | ]); 34 | 35 | // Laravel-specific rules 36 | $rectorConfig->rules([ 37 | AddExtendsAnnotationToModelFactoriesRector::class, 38 | MigrateToSimplifiedAttributeRector::class, 39 | ReplaceFakerInstanceWithHelperRector::class, 40 | ]); 41 | 42 | $rectorConfig->skip([ 43 | // Skip certain rules if needed 44 | ]); 45 | 46 | $rectorConfig->parallel(); 47 | $rectorConfig->importNames(); 48 | $rectorConfig->importShortClasses(); 49 | }; 50 | -------------------------------------------------------------------------------- /src/Contracts/MailDriverContract.php: -------------------------------------------------------------------------------- 1 | getMessage(), [ 37 | 'title' => $this->getTitle(), 38 | 'color' => 0xF44336, 39 | ]); 40 | } 41 | 42 | public function toSlack(): SlackMessage 43 | { 44 | return (new SlackMessage) 45 | ->content($this->getMessage()); 46 | } 47 | 48 | public function toTelegram(): TelegramMessage 49 | { 50 | return TelegramMessage::create() 51 | ->content($this->getMessage()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Traits/AssociatesModels.php: -------------------------------------------------------------------------------- 1 | all(); 24 | } elseif (! is_array($model)) { 25 | $model = Arr::wrap($model); 26 | } 27 | 28 | $this->associateMany($model); 29 | } 30 | 31 | /** 32 | * @param array $models 33 | */ 34 | public function associateMany(array $models): void 35 | { 36 | $header = $this->getEncryptedAssociatedModelsHeader($models); 37 | 38 | $this->withSymfonyMessage(fn (Email $email): Headers => $email->getHeaders()->addTextHeader( 39 | config('mails.headers.associate'), 40 | $header, 41 | )); 42 | } 43 | 44 | /** 45 | * @param array $models 46 | */ 47 | protected function getEncryptedAssociatedModelsHeader(array $models): string 48 | { 49 | $identifiers = []; 50 | 51 | foreach ($models as $model) { 52 | $identifiers[] = [$model::class, $model->getKeyname(), $model->getKey()]; 53 | } 54 | 55 | $header = json_encode($identifiers); 56 | 57 | return encrypt($header); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /database/migrations/1_create_mails_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('uuid')->nullable()->index(); 14 | $table->string('mail_class')->nullable()->index(); 15 | $table->string('subject')->nullable(); 16 | $table->json('from')->nullable(); 17 | $table->json('reply_to')->nullable(); 18 | $table->json('to')->nullable(); 19 | $table->json('cc')->nullable(); 20 | $table->json('bcc')->nullable(); 21 | $table->text('html')->nullable(); 22 | $table->text('text')->nullable(); 23 | $table->unsignedBigInteger('opens')->default(0); 24 | $table->unsignedBigInteger('clicks')->default(0); 25 | $table->timestamp('sent_at')->nullable(); 26 | $table->timestamp('resent_at')->nullable(); 27 | $table->timestamp('accepted_at')->nullable(); 28 | $table->timestamp('delivered_at')->nullable(); 29 | $table->timestamp('last_opened_at')->nullable(); 30 | $table->timestamp('last_clicked_at')->nullable(); 31 | $table->timestamp('complained_at')->nullable(); 32 | $table->timestamp('soft_bounced_at')->nullable(); 33 | $table->timestamp('hard_bounced_at')->nullable(); 34 | $table->timestamp('unsubscribed_at')->nullable(); 35 | $table->timestamps(); 36 | }); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /database/factories/MailFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->uuid, 16 | 'mailer' => 'smtp', 17 | 'mail_class' => '', 18 | 'subject' => $this->faker->sentence(5), 19 | 'from' => [ 20 | $this->faker->email => $this->faker->firstName(), 21 | ], 22 | 'reply_to' => null, 23 | 'to' => [ 24 | $this->faker->email => $this->faker->firstName(), 25 | ], 26 | 'cc' => null, 27 | 'bcc' => null, 28 | 'sent_at' => now(), 29 | 'delivered_at' => null, 30 | 'last_opened_at' => null, 31 | 'last_clicked_at' => null, 32 | 'complained_at' => null, 33 | 'soft_bounced_at' => null, 34 | 'hard_bounced_at' => null, 35 | ]; 36 | } 37 | 38 | public function hasCc(): MailFactory 39 | { 40 | return $this->state(function () { 41 | return [ 42 | 'cc' => [ 43 | $this->faker->email => $this->faker->firstName(), 44 | ], 45 | ]; 46 | }); 47 | } 48 | 49 | public function hasBcc(): MailFactory 50 | { 51 | return $this->state(function () { 52 | return [ 53 | 'bcc' => [ 54 | $this->faker->email => $this->faker->firstName(), 55 | ], 56 | ]; 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Notifications/BounceNotification.php: -------------------------------------------------------------------------------- 1 | greeting($this->getTitle()) 39 | ->line($this->getMessage()); 40 | } 41 | 42 | public function toDiscord(): DiscordMessage 43 | { 44 | return DiscordMessage::create($this->getMessage(), [ 45 | 'title' => $this->getTitle(), 46 | 'color' => 0xF44336, 47 | ]); 48 | } 49 | 50 | public function toSlack(): SlackMessage 51 | { 52 | return (new SlackMessage) 53 | ->content($this->getMessage()); 54 | } 55 | 56 | public function toTelegram(): TelegramMessage 57 | { 58 | return TelegramMessage::create() 59 | ->content($this->getMessage()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Actions/AttachUuid.php: -------------------------------------------------------------------------------- 1 | getProvider($messageSending); 17 | 18 | if (! $this->shouldTrackMails($provider)) { 19 | return $messageSending; 20 | } 21 | 22 | $uuid = Str::uuid()->toString(); 23 | 24 | $messageSending->message->getHeaders()->addTextHeader(config('mails.headers.uuid'), $uuid); 25 | 26 | return MailProvider::with($provider)->attachUuidToMail($messageSending, $uuid); 27 | } 28 | 29 | public function getProvider(MessageSending $messageSending): string 30 | { 31 | return config('mail.mailers.'.$messageSending->data['mailer'].'.transport') ?? $messageSending->data['mailer']; 32 | } 33 | 34 | public function shouldTrackMails(string $provider): bool 35 | { 36 | return $this->trackingEnabled() && 37 | $this->driverExistsForProvider($provider); 38 | } 39 | 40 | public function driverExistsForProvider(string $provider): bool 41 | { 42 | return class_exists('Backstage\\Mails\\Drivers\\'.ucfirst($provider).'Driver'); 43 | } 44 | 45 | public function trackingEnabled(): bool 46 | { 47 | return (bool) config('mails.logging.tracking.bounces') || 48 | (bool) config('mails.logging.tracking.clicks') || 49 | (bool) config('mails.logging.tracking.complaints') || 50 | (bool) config('mails.logging.tracking.deliveries') || 51 | (bool) config('mails.logging.tracking.opens') || 52 | (bool) config('mails.logging.tracking.unsubscribes'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Notifications/HighBounceRateNotification.php: -------------------------------------------------------------------------------- 1 | rate}%, the configured max is set at {$this->threshold}"; 36 | } 37 | 38 | public function toMail(): MailMessage 39 | { 40 | return (new MailMessage) 41 | ->greeting($this->getTitle()) 42 | ->line($this->getMessage()); 43 | } 44 | 45 | public function toDiscord(): DiscordMessage 46 | { 47 | return DiscordMessage::create($this->getMessage(), [ 48 | 'title' => $this->getTitle(), 49 | 'color' => 0xF44336, 50 | ]); 51 | } 52 | 53 | public function toSlack(): SlackMessage 54 | { 55 | return (new SlackMessage) 56 | ->content($this->getMessage()); 57 | } 58 | 59 | public function toTelegram(): TelegramMessage 60 | { 61 | return TelegramMessage::create() 62 | ->content($this->getMessage()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Commands/CheckBounceRateCommand.php: -------------------------------------------------------------------------------- 1 | $mail 20 | * @var int $threshold 21 | * @var int $retain 22 | */ 23 | [$mail, $threshold, $retain] = array_values(config()->get([ 24 | 'mails.models.mail', 25 | 'mails.events.bouncerate.treshold', 26 | 'mails.events.bouncerate.retain', 27 | ])); 28 | 29 | $until = now()->subDays($retain); 30 | 31 | $all = call_user_func_array([$mail, 'query'], []) 32 | ->whereTime('created_at', '<', $until) 33 | ->count(); 34 | 35 | if ($all < 1) { 36 | $this->components->error('No mails have been sent.'); 37 | 38 | return self::FAILURE; 39 | } 40 | 41 | $bounced = call_user_func_array([$mail, 'query'], []) 42 | ->whereTime('created_at', '<', $until) 43 | ->whereNotNull('hard_bounced_at') 44 | ->orWhereNotNull('soft_bounced_at') 45 | ->count(); 46 | 47 | $rate = round(($bounced / $all) * 100, 2); 48 | 49 | if ($rate > $threshold) { 50 | (new SendHighBounceRateNotifications)($rate, $threshold); 51 | 52 | $this->components->error( 53 | "Bounce rate is {$rate}%, that's higher than the configured threshold of {$threshold}%", 54 | ); 55 | 56 | return self::FAILURE; 57 | } 58 | 59 | $this->components->info("Bounce rate is {$rate}%"); 60 | 61 | return self::SUCCESS; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Listeners/StoreMailRelations.php: -------------------------------------------------------------------------------- 1 | message; 14 | 15 | if (! $this->shouldAssociateModels($message)) { 16 | return; 17 | } 18 | 19 | $models = $this->getAssociatedModels($message); 20 | 21 | $mail = $this->getMailModel($message); 22 | 23 | foreach ($models as $identifier) { 24 | [$model, $keyName, $id] = $identifier; 25 | 26 | $model = $model::query()->where($keyName, $id)->limit(1)->first(); 27 | 28 | $model->associateMail($mail); 29 | } 30 | } 31 | 32 | protected function shouldAssociateModels(Email $email): bool 33 | { 34 | return $email->getHeaders()->has( 35 | $this->getAssociatedHeaderName(), 36 | ); 37 | } 38 | 39 | protected function getAssociatedModels(Email $email): array|false 40 | { 41 | $encrypted = $this->getHeaderBody( 42 | $email, 43 | $this->getAssociatedHeaderName(), 44 | ); 45 | 46 | $payload = decrypt($encrypted); 47 | 48 | return json_decode((string) $payload, true); 49 | } 50 | 51 | protected function getMailModel(Email $email): Model 52 | { 53 | $uuid = $this->getHeaderBody($email, config('mails.headers.uuid')); 54 | 55 | $model = config('mails.models.mail'); 56 | 57 | return $model::query()->where('uuid', $uuid)->limit(1)->first(); 58 | } 59 | 60 | protected function getHeaderBody(Email $email, string $header): mixed 61 | { 62 | return $email->getHeaders()->getHeaderBody($header); 63 | } 64 | 65 | protected function getAssociatedHeaderName(): string 66 | { 67 | return config('mails.headers.associate', 'X-Mails-Associated-Models'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Models/MailAttachment.php: -------------------------------------------------------------------------------- 1 | 'string', 39 | 'uuid' => 'string', 40 | 'filename' => 'string', 41 | 'mime' => 'string', 42 | 'inline' => 'boolean', 43 | 'size' => 'integer', 44 | ]; 45 | 46 | public function getTable() 47 | { 48 | return config('mails.database.tables.attachments'); 49 | } 50 | 51 | protected static function newFactory(): Factory 52 | { 53 | return MailAttachmentFactory::new(); 54 | } 55 | 56 | public function mail(): BelongsTo 57 | { 58 | return $this->belongsTo(config('mails.models.mail')); 59 | } 60 | 61 | protected function storagePath(): Attribute 62 | { 63 | return Attribute::make(get: fn (): string => rtrim((string) config('mails.logging.attachments.root'), '/').'/'.$this->getKey().'/'.$this->filename); 64 | } 65 | 66 | protected function fileData(): Attribute 67 | { 68 | return Attribute::make(get: fn () => Storage::disk($this->disk)->get($this->storagePath)); 69 | } 70 | 71 | public function downloadFileFromStorage(?string $filename = null): string 72 | { 73 | return Storage::disk($this->disk) 74 | ->download( 75 | $this->storagePath, 76 | $filename ?? $this->filename, [ 77 | 'Content-Type' => $this->mime, 78 | ]); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Drivers/ResendDriver.php: -------------------------------------------------------------------------------- 1 | warn("Resend doesn't allow registering webhooks via the API. "); 15 | $components->info('Please register your webhooks manually in the Resend dashboard.'); 16 | } 17 | 18 | public function verifyWebhookSignature(array $payload): bool 19 | { 20 | return true; 21 | } 22 | 23 | public function getUuidFromPayload(array $payload): ?string 24 | { 25 | return collect($payload['data']['headers']) 26 | ->where('name', config('mails.headers.uuid')) 27 | ->first()['value'] ?? null; 28 | } 29 | 30 | protected function getTimestampFromPayload(array $payload): string 31 | { 32 | return $payload['data']['created_at'] ?? now(); 33 | } 34 | 35 | public function eventMapping(): array 36 | { 37 | return [ 38 | EventType::ACCEPTED->value => ['type' => 'email.sent'], 39 | EventType::CLICKED->value => ['type' => 'email.clicked'], 40 | EventType::COMPLAINED->value => ['type' => 'email.complained'], 41 | EventType::DELIVERED->value => ['type' => 'email.delivered'], 42 | EventType::HARD_BOUNCED->value => ['type' => 'email.bounced'], 43 | EventType::OPENED->value => ['type' => 'email.opened'], 44 | EventType::SOFT_BOUNCED->value => ['type' => 'email.delivery_delayed'], 45 | ]; 46 | } 47 | 48 | public function dataMapping(): array 49 | { 50 | return [ 51 | 'ip_address' => 'data.click.ipAddress', 52 | 'link' => 'data.click.link', 53 | 'user_agent' => 'data.click.userAgent', 54 | ]; 55 | } 56 | 57 | public function attachUuidToMail(MessageSending $messageSending, string $uuid): MessageSending 58 | { 59 | $messageSending->message->getHeaders()->addTextHeader(config('mails.headers.uuid'), $uuid); 60 | 61 | return $messageSending; 62 | } 63 | 64 | public function unsuppressEmailAddress(string $address, ?int $stream_id = null): Response 65 | { 66 | // Resend doesn't support unsuppressing email addresses via API 67 | return new Response(new \GuzzleHttp\Psr7\Response(200, [], 'Not supported')); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Jobs/ResendMailJob.php: -------------------------------------------------------------------------------- 1 | $to 21 | */ 22 | public function __construct( 23 | private readonly Mailable $mailable, 24 | private array $to, 25 | private array $cc = [], 26 | private array $bcc = [], 27 | private array $replyTo = [] 28 | ) { 29 | // 30 | } 31 | 32 | public function handle(): void 33 | { 34 | Mail::send([], [], function (Message $message): void { 35 | $this->setMessageContent($message) 36 | ->setMessageRecipients($message); 37 | }); 38 | } 39 | 40 | private function setMessageContent(Message $message): self 41 | { 42 | $message->html($this->mailable->html ?? '') 43 | ->text($this->mailable->text ?? ''); 44 | 45 | foreach ($this->mailable->attachments as $attachment) { 46 | $message->attachData( 47 | $attachment->file_data ?? $attachment->fileData ?? '', 48 | $attachment->file_name ?? $attachment->filename ?? '', 49 | ['mime' => $attachment->mime_type ?? $attachment->mime ?? ''] 50 | ); 51 | } 52 | 53 | return $this; 54 | } 55 | 56 | private function setMessageRecipients(Message $message): self 57 | { 58 | $message->subject($this->mailable->subject ?? '') 59 | ->from(array_keys($this->mailable->from)[0], array_values($this->mailable->from)[0]) 60 | ->to($this->to); 61 | 62 | if ($this->mailable->cc || count($this->cc) > 0) { 63 | $message->cc($this->mailable->cc ?? $this->cc); 64 | } 65 | 66 | if ($this->mailable->bcc || count($this->bcc) > 0) { 67 | $message->bcc($this->mailable->bcc ?? $this->bcc); 68 | } 69 | 70 | if ($this->mailable->reply_to || $this->replyTo) { 71 | $message->replyTo($this->mailable->reply_to ?? $this->replyTo); 72 | } 73 | 74 | return $this; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Commands/ResendMailCommand.php: -------------------------------------------------------------------------------- 1 | argument('uuid'); 22 | 23 | $mail = mail::where('uuid', $uuid)->first(); 24 | 25 | if (is_null($mail)) { 26 | $this->components->error("Mail with uuid: \"{$uuid}\" does not exist"); 27 | 28 | return Command::FAILURE; 29 | } 30 | 31 | info('For the next prompts you can input multiple email addresses by separating them with a comma.'); 32 | 33 | [$to, $cc, $bcc] = $this->promptemailinputs($mail); 34 | 35 | ResendMailJob::dispatch($mail, $to, $cc, $bcc); 36 | 37 | info('All done'); 38 | 39 | return self::SUCCESS; 40 | } 41 | 42 | protected function promptEmailInputs(Mail $mail): array 43 | { 44 | $to = in_array(implode(',', $this->argument('to')), ['', '0'], true) ? text( 45 | label: 'What email address do you want to send the mail to?', 46 | placeholder: 'test@example.com', 47 | ) : implode(',', $this->argument('to')); 48 | 49 | $cc = in_array(implode(',', $this->option('cc')), ['', '0'], true) ? text( 50 | label: 'What email address should be included in the cc?', 51 | placeholder: 'test@example.com', 52 | ) : implode(',', $this->option('cc')); 53 | 54 | $bcc = in_array(implode(',', $this->option('bcc')), ['', '0'], true) ? text( 55 | label: 'What email address should be included in the bcc?', 56 | placeholder: 'test@example.com', 57 | ) : implode(',', $this->option('bcc')); 58 | 59 | foreach ([&$to, &$cc, &$bcc] as &$input) { 60 | $input = array_filter(array_map(fn ($s): string => trim($s), explode(' ', str_replace([',', ';'], ' ', $input)))); 61 | } 62 | 63 | return [$to !== '' && $to !== '0' ? $to : $mail->to, $cc !== '' && $cc !== '0' ? $cc : $mail->cc ?? [], $bcc !== '' && $bcc !== '0' ? $bcc : $mail->bcc ?? []]; 64 | } 65 | 66 | protected function promptForMissingArgumentsUsing() 67 | { 68 | return ['uuid' => 'What is the UUID of the email you want to re-send?']; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backstage/laravel-mails", 3 | "description": "Laravel Mails can collect everything you might want to track about the mails that has been sent by your Laravel app.", 4 | "keywords": [ 5 | "backstagephp", 6 | "laravel", 7 | "laravel-mails" 8 | ], 9 | "homepage": "https://github.com/backstagephp/laravel-mails", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Mark van Eijk", 14 | "email": "mark@vormkracht10.nl", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "illuminate/contracts": "^10.0 || ^11.0 || ^12.0", 21 | "laravel/helpers": "^1.7.0", 22 | "spatie/laravel-package-tools": "^1.15.0" 23 | }, 24 | "require-dev": { 25 | "driftingly/rector-laravel": "^2.1", 26 | "larastan/larastan": "^3.0", 27 | "laravel-notification-channels/discord": "^1.6", 28 | "laravel-notification-channels/telegram": "^4.0 || ^5.0 || ^6.0", 29 | "laravel/pint": "^1.17.0", 30 | "laravel/slack-notification-channel": "^2.5 || ^3.3.2", 31 | "nunomaduro/collision": "^7.5.0|^8.4", 32 | "orchestra/testbench": "^8.5.0|^9.4.0", 33 | "pestphp/pest": "^3.0", 34 | "pestphp/pest-plugin-laravel": "^3.0", 35 | "phpstan/extension-installer": "^1.4", 36 | "phpstan/phpstan-deprecation-rules": "^2.0", 37 | "phpstan/phpstan-phpunit": "^2.0", 38 | "phpunit/phpunit": "^11.0", 39 | "rector/rector": "^2.2", 40 | "rector/rector-laravel": "^2.1" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Backstage\\Mails\\": "src", 45 | "Backstage\\Mails\\Database\\Factories\\": "database/factories" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "Backstage\\Mails\\Tests\\": "tests" 51 | }, 52 | "files": [ 53 | "helpers.php" 54 | ] 55 | }, 56 | "scripts": { 57 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 58 | "analyse": "@php vendor/bin/phpstan analyse", 59 | "test": "@php vendor/bin/pest", 60 | "test-coverage": "@php vendor/bin/pest --coverage", 61 | "format": "@php vendor/bin/pint" 62 | }, 63 | "config": { 64 | "sort-packages": true, 65 | "allow-plugins": { 66 | "pestphp/pest-plugin": true, 67 | "phpstan/extension-installer": true, 68 | "php-http/discovery": true 69 | } 70 | }, 71 | "conflict": { 72 | "spatie/laravel-ray": "*" 73 | }, 74 | "extra": { 75 | "laravel": { 76 | "providers": [ 77 | "Backstage\\Mails\\MailsServiceProvider" 78 | ] 79 | } 80 | }, 81 | "minimum-stability": "dev", 82 | "prefer-stable": true 83 | } 84 | -------------------------------------------------------------------------------- /src/Models/MailEvent.php: -------------------------------------------------------------------------------- 1 | EventType::class, 59 | 'payload' => 'object', 60 | 'created_at' => 'datetime', 61 | 'updated_at' => 'datetime', 62 | 'occurred_at' => 'datetime', 63 | 'unsuppressed_at' => 'datetime', 64 | ]; 65 | 66 | public function getTable() 67 | { 68 | return config('mails.database.tables.events'); 69 | } 70 | 71 | protected static function booted(): void 72 | { 73 | static::creating(function (MailEvent $mailEvent): void { 74 | event(MailEventLogged::class, $mailEvent); 75 | 76 | $eventClass = $mailEvent->eventClass; 77 | 78 | if (class_exists($eventClass)) { 79 | $eventClass::dispatch($mailEvent); 80 | } 81 | }); 82 | } 83 | 84 | protected static function newFactory(): Factory 85 | { 86 | return MailEventFactory::new(); 87 | } 88 | 89 | public function mail() 90 | { 91 | return $this->belongsTo(config('mails.models.mail')); 92 | } 93 | 94 | public function scopeSuppressed(Builder $builder): void 95 | { 96 | $builder->where('type', EventType::HARD_BOUNCED); 97 | } 98 | 99 | protected function eventClass(): Attribute 100 | { 101 | return Attribute::make(get: fn (): string => 'Backstage\Mails\Events\Mail'.Str::studly($this->type->value)); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/MailsServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['events']->listen(MailEvent::class, LogMailEvent::class); 34 | 35 | $this->app['events']->listen(MessageSending::class, AttachMailLogUuid::class); 36 | $this->app['events']->listen(MessageSending::class, LogSendingMail::class); 37 | $this->app['events']->listen(MessageSending::class, StoreMailRelations::class); 38 | 39 | $this->app['events']->listen(MessageSent::class, LogSentMail::class); 40 | $this->app['events']->listen(MailHardBounced::class, NotifyOnBounce::class); 41 | $this->app['events']->listen(MailUnsuppressed::class, UnsuppressEmailAddress::class); 42 | } 43 | 44 | public function bootingPackage(): void 45 | { 46 | $this->app->singleton(MailProviderContract::class, fn ($app): MailProviderManager => new MailProviderManager($app)); 47 | } 48 | 49 | public function configurePackage(Package $package): void 50 | { 51 | $package 52 | ->name('laravel-mails') 53 | ->hasConfigFile() 54 | ->hasViews() 55 | ->hasMigrations($this->getMigrations()) 56 | ->hasRoutes('webhooks') 57 | ->hasCommands( 58 | MonitorMailCommand::class, 59 | PruneMailCommand::class, 60 | ResendMailCommand::class, 61 | WebhooksMailCommand::class, 62 | CheckBounceRateCommand::class, 63 | ); 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | protected function getMigrations(): array 70 | { 71 | return collect(app(Filesystem::class)->files(__DIR__.'/../database/migrations')) 72 | ->map(fn (SplFileInfo $file): string => str_replace('.php.stub', '', $file->getBasename())) 73 | ->toArray(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /config/mails.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'mail' => Mail::class, 13 | 'event' => MailEvent::class, 14 | 'attachment' => MailAttachment::class, 15 | ], 16 | 17 | // Table names for saving sent emails and polymorphic relations to database 18 | 19 | 'database' => [ 20 | 'tables' => [ 21 | 'mails' => 'mails', 22 | 'attachments' => 'mail_attachments', 23 | 'events' => 'mail_events', 24 | 'polymorph' => 'mailables', 25 | ], 26 | 27 | 'pruning' => [ 28 | 'enabled' => true, 29 | 'after' => 30, // days 30 | ], 31 | ], 32 | 33 | 'headers' => [ 34 | 'uuid' => 'X-Mails-UUID', 35 | 36 | 'associate' => 'X-Mails-Associated-Models', 37 | ], 38 | 39 | 'webhooks' => [ 40 | 'routes' => [ 41 | 'prefix' => 'webhooks/mails', 42 | ], 43 | 44 | 'queue' => env('MAILS_QUEUE_WEBHOOKS', false), 45 | ], 46 | 47 | // Logging mails 48 | 'logging' => [ 49 | 50 | // Enable logging of all sent mails to database 51 | 52 | 'enabled' => env('MAILS_LOGGING_ENABLED', true), 53 | 54 | // Specify attributes to log in database 55 | 56 | 'attributes' => [ 57 | 'subject', 58 | 'from', 59 | 'to', 60 | 'reply_to', 61 | 'cc', 62 | 'bcc', 63 | 'html', 64 | 'text', 65 | ], 66 | 67 | // Encrypt all attributes saved to database 68 | 69 | 'encrypted' => env('MAILS_ENCRYPTED', true), 70 | 71 | // Track following events using webhooks from email provider 72 | 73 | 'tracking' => [ 74 | 'bounces' => true, 75 | 'clicks' => true, 76 | 'complaints' => true, 77 | 'deliveries' => true, 78 | 'opens' => true, 79 | 'unsubscribes' => true, 80 | ], 81 | 82 | // Enable saving mail attachments to disk 83 | 84 | 'attachments' => [ 85 | 'enabled' => env('MAILS_LOGGING_ATTACHMENTS_ENABLED', true), 86 | 'disk' => env('FILESYSTEM_DISK', 'local'), 87 | 'root' => 'mails/attachments', 88 | ], 89 | ], 90 | 91 | // Notifications for important mail events 92 | 93 | 'notifications' => [ 94 | 'mail' => [ 95 | 'to' => ['test@example.com'], 96 | ], 97 | 98 | 'discord' => [ 99 | // 'to' => ['1234567890'], 100 | ], 101 | 102 | 'slack' => [ 103 | // 'to' => ['https://hooks.slack.com/services/...'], 104 | ], 105 | 106 | 'telegram' => [ 107 | // 'to' => ['1234567890'], 108 | ], 109 | ], 110 | 111 | 'events' => [ 112 | 'soft_bounced' => [ 113 | 'notify' => ['mail'], 114 | ], 115 | 116 | 'hard_bounced' => [ 117 | 'notify' => ['mail'], 118 | ], 119 | 120 | 'bouncerate' => [ 121 | 'notify' => [], 122 | 123 | 'retain' => 30, // days 124 | 125 | 'treshold' => 1, // % 126 | ], 127 | 128 | 'deliveryrate' => [ 129 | 'treshold' => 99, 130 | ], 131 | 132 | 'complained' => [ 133 | // 134 | ], 135 | 136 | 'unsent' => [ 137 | // 138 | ], 139 | ], 140 | 141 | ]; 142 | -------------------------------------------------------------------------------- /src/Drivers/MailDriver.php: -------------------------------------------------------------------------------- 1 | mailModel = config('mails.models.mail'); 20 | $this->mailEventModel = config('mails.models.event'); 21 | $this->uuidHeaderName = config('mails.headers.uuid'); 22 | } 23 | 24 | abstract protected function getUuidFromPayload(array $payload): ?string; 25 | 26 | abstract protected function dataMapping(): array; 27 | 28 | abstract protected function getTimestampFromPayload(array $payload): string; 29 | 30 | abstract protected function eventMapping(): array; 31 | 32 | public function getMailFromPayload(array $payload): ?Mail 33 | { 34 | return $this->mailModel::query() 35 | ->firstWhere('uuid', $this->getUuidFromPayload($payload)); 36 | } 37 | 38 | public function getDataFromPayload(array $payload): array 39 | { 40 | return collect($this->dataMapping()) 41 | ->mapWithKeys(fn ($value, $key): array => [ 42 | $key => is_array($v = data_get($payload, $value)) ? json_encode($v) : $v, 43 | ]) 44 | ->filter() 45 | ->merge([ 46 | 'payload' => $payload, 47 | 'type' => $this->getEventFromPayload($payload), 48 | 'occurred_at' => $this->getTimestampFromPayload($payload), 49 | ]) 50 | ->toArray(); 51 | } 52 | 53 | public function getEventFromPayload(array $payload): string 54 | { 55 | foreach ($this->eventMapping() as $event => $mapping) { 56 | if (collect($mapping)->every(fn ($value, $key): bool => data_get($payload, $key) === $value)) { 57 | return $event; 58 | } 59 | } 60 | 61 | throw LaravelMailException::unknownEventType(); 62 | } 63 | 64 | public function logMailEvent(array $payload): void 65 | { 66 | $mail = $this->getMailFromPayload($payload); 67 | 68 | if (is_null($mail)) { 69 | return; 70 | } 71 | 72 | $data = $this->getDataFromPayload($payload); 73 | $method = Str::camel($data['type']); 74 | 75 | if (method_exists($this, $method)) { 76 | // log mail event 77 | $mail->events()->create($data); 78 | 79 | // update mail record with timestamp 80 | $this->{$method}($mail, $this->getTimestampFromPayload($payload)); 81 | } 82 | } 83 | 84 | public function accepted(Mail $mail, string $timestamp): void 85 | { 86 | $mail->update([ 87 | 'accepted_at' => $timestamp, 88 | ]); 89 | } 90 | 91 | public function clicked(Mail $mail, string $timestamp): void 92 | { 93 | $mail->update([ 94 | 'last_clicked_at' => $timestamp, 95 | 'clicks' => $mail->clicks + 1, 96 | ]); 97 | } 98 | 99 | public function complained(Mail $mail, string $timestamp): void 100 | { 101 | $mail->update([ 102 | 'complained_at' => $timestamp, 103 | ]); 104 | } 105 | 106 | public function delivered(Mail $mail, string $timestamp): void 107 | { 108 | $mail->update([ 109 | 'delivered_at' => $timestamp, 110 | ]); 111 | } 112 | 113 | public function hardBounced(Mail $mail, string $timestamp): void 114 | { 115 | $mail->update([ 116 | 'hard_bounced_at' => $timestamp, 117 | ]); 118 | } 119 | 120 | public function softBounced(Mail $mail, string $timestamp): void 121 | { 122 | $mail->update([ 123 | 'soft_bounced_at' => $timestamp, 124 | ]); 125 | } 126 | 127 | public function opened(Mail $mail, string $timestamp): void 128 | { 129 | $mail->update([ 130 | 'last_opened_at' => $timestamp, 131 | 'opens' => $mail->opens + 1, 132 | ]); 133 | } 134 | 135 | public function unsubscribed(Mail $mail, string $timestamp): void 136 | { 137 | $mail->update([ 138 | 'unsubscribed_at' => $timestamp, 139 | ]); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Actions/LogMail.php: -------------------------------------------------------------------------------- 1 | newMailModelInstance(); 25 | 26 | if ($event instanceof MessageSending) { 27 | $mail->fill($this->getOnlyConfiguredAttributes($event)); 28 | $mail->save(); 29 | 30 | $this->collectAttachments($mail, $event->message->getAttachments()); 31 | } 32 | 33 | if ($event instanceof MessageSent) { 34 | $mail = $mail->firstWhere('uuid', $this->getCustomUuid($event)); 35 | 36 | $mail->update($this->getOnlyConfiguredAttributes($event)); 37 | } 38 | 39 | return null; 40 | } 41 | 42 | /** 43 | * @return Mail 44 | */ 45 | public function newMailModelInstance() 46 | { 47 | $model = config('mails.models.mail'); 48 | 49 | return new $model; 50 | } 51 | 52 | public function getOnlyConfiguredAttributes(MessageSending|MessageSent $event): array 53 | { 54 | return collect($this->getDefaultLogAttributes($event)) 55 | ->only($this->getConfiguredAttributes()) 56 | ->merge($this->getMandatoryAttributes($event)) 57 | ->merge([ 58 | 'mailer' => $event->data['mailer'], 59 | 'transport' => config('mail.mailers.'.$event->data['mailer'].'.transport'), 60 | ]) 61 | ->toArray(); 62 | } 63 | 64 | public function getConfiguredAttributes(): array 65 | { 66 | return (array) config('mails.logging.attributes'); 67 | } 68 | 69 | public function getDefaultLogAttributes(MessageSending|MessageSent $event): array 70 | { 71 | return [ 72 | 'subject' => $event->message->getSubject(), 73 | 'from' => $this->getAddressesValue($event->message->getFrom()), 74 | 'reply_to' => $this->getAddressesValue($event->message->getReplyTo()), 75 | 'to' => $this->getAddressesValue($event->message->getTo()), 76 | 'cc' => $this->getAddressesValue($event->message->getCc()), 77 | 'bcc' => $this->getAddressesValue($event->message->getBcc()), 78 | 'html' => $event->message->getHtmlBody(), 79 | 'text' => $event->message->getTextBody(), 80 | 'tags' => collect($event->message->getHeaders()->all('X-tag'))->map(fn ($tag) => $tag->getValue())->toArray(), 81 | ]; 82 | } 83 | 84 | protected function getStreamId(MessageSending|MessageSent $event): ?string 85 | { 86 | if ($event->data['mailer'] !== Provider::POSTMARK) { 87 | return null; 88 | } 89 | 90 | if (null !== $messageStream = $event->message->getHeaders()->get('x-pm-message-stream')) { 91 | return $messageStream->getBody(); 92 | } 93 | 94 | return config('mail.mailers.postmark.message_stream_id', 'outbound'); 95 | } 96 | 97 | public function getMandatoryAttributes(MessageSending|MessageSent $event): array 98 | { 99 | return [ 100 | 'uuid' => $this->getCustomUuid($event), 101 | // 'mail_class' => $this->getMailClassHeaderValue($event), 102 | 'sent_at' => $event instanceof MessageSent ? now() : null, 103 | 'mailer' => $event->data['mailer'], 104 | 'stream_id' => $this->getStreamId($event), 105 | ]; 106 | } 107 | 108 | protected function getCustomUuid(MessageSending|MessageSent $event): ?string 109 | { 110 | if (! $event->message->getHeaders()->has(config('mails.headers.uuid'))) { 111 | return null; 112 | } 113 | 114 | $headerValue = $event->message->getHeaders()->get(config('mails.headers.uuid')); 115 | 116 | if (is_null($headerValue)) { 117 | return null; 118 | } 119 | 120 | return $headerValue->getBodyAsString(); 121 | } 122 | 123 | protected function getAddressesValue(array $address): ?Collection 124 | { 125 | $addresses = collect($address) 126 | ->flatMap(fn (Address $address): array => [$address->getAddress() => $address->getName() === '' ? null : $address->getName()]); 127 | 128 | return $addresses->count() > 0 ? $addresses : null; 129 | } 130 | 131 | public function collectAttachments($mail, $attachments): void 132 | { 133 | collect($attachments)->each(function ($attachment) use ($mail): void { 134 | $attachmentModel = $mail->attachments()->create([ 135 | 'disk' => config('mails.logging.attachments.disk'), 136 | 'uuid' => $attachment->getContentId(), 137 | 'filename' => $attachment->getFilename(), 138 | 'mime' => $attachment->getContentType(), 139 | 'inline' => ! str_contains((string) $attachment->getFilename() !== '' && (string) $attachment->getFilename() !== '0' ? (string) $attachment->getFilename() : '', '.'), // workaround, because disposition is a private property 140 | 'size' => strlen((string) $attachment->getBody()), 141 | ]); 142 | 143 | $this->saveAttachment($attachmentModel, $attachment); 144 | }); 145 | } 146 | 147 | public function saveAttachment($attachmentModel, $attachment): void 148 | { 149 | Storage::disk($attachmentModel->disk) 150 | ->put($attachmentModel->storagePath, $attachment->getBody(), 'private'); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Drivers/MailgunDriver.php: -------------------------------------------------------------------------------- 1 | Provider::MAILGUN]); 25 | 26 | $events = []; 27 | 28 | if ((bool) $trackingConfig['opens']) { 29 | $events[] = 'opened'; 30 | } 31 | 32 | if ((bool) $trackingConfig['clicks']) { 33 | $events[] = 'clicked'; 34 | } 35 | 36 | if ((bool) $trackingConfig['deliveries']) { 37 | $events[] = 'accepted'; 38 | $events[] = 'delivered'; 39 | } 40 | 41 | if ((bool) $trackingConfig['bounces']) { 42 | $events[] = 'permanent_fail'; 43 | $events[] = 'temporary_fail'; 44 | } 45 | 46 | if ((bool) $trackingConfig['complaints']) { 47 | $events[] = 'complained'; 48 | } 49 | 50 | if ((bool) $trackingConfig['unsubscribes']) { 51 | $events[] = 'unsubscribed'; 52 | } 53 | 54 | foreach ($events as $event) { 55 | $response = Http::withBasicAuth('api', $apiKey) 56 | ->asMultipart() 57 | ->post("$scheme://$endpoint/v3/domains/$domain/webhooks", [ 58 | 'id' => $event, 59 | 'url' => $webhookUrl, 60 | ]); 61 | 62 | $message = $response->json()['message'] ?? null; 63 | 64 | if ($response->successful()) { 65 | $components->info("Created Mailgun webhook for: $event"); 66 | } elseif ($message === 'Webhook already exists') { 67 | $components->warn("A Mailgun webhook already exists for: $event"); 68 | $components->info("Please make sure that it is: $webhookUrl"); 69 | } else { 70 | $components->warn("Failed to create Mailgun webhook for: $event"); 71 | $components->error($message); 72 | } 73 | } 74 | } 75 | 76 | public function verifyWebhookSignature(array $payload): bool 77 | { 78 | if (app()->runningUnitTests()) { 79 | return true; 80 | } 81 | 82 | if (empty($payload['signature']['timestamp']) || empty($payload['signature']['token']) || empty($payload['signature']['signature'])) { 83 | return false; 84 | } 85 | 86 | $hmac = hash_hmac('sha256', $payload['signature']['timestamp'].$payload['signature']['token'], (string) config('services.mailgun.webhook_signing_key')); 87 | 88 | if (function_exists('hash_equals')) { 89 | return hash_equals($hmac, $payload['signature']['signature']); 90 | } 91 | 92 | return $hmac === $payload['signature']['signature']; 93 | } 94 | 95 | public function attachUuidToMail(MessageSending $messageSending, string $uuid): MessageSending 96 | { 97 | $messageSending->message->getHeaders()->addTextHeader('X-Mailgun-Variables', json_encode([config('mails.headers.uuid') => $uuid])); 98 | 99 | return $messageSending; 100 | } 101 | 102 | public function getUuidFromPayload(array $payload): ?string 103 | { 104 | return $payload['event-data']['user-variables'][$this->uuidHeaderName] ?? null; 105 | } 106 | 107 | protected function getTimestampFromPayload(array $payload): string 108 | { 109 | return $payload['event-data']['timestamp']; 110 | } 111 | 112 | public function eventMapping(): array 113 | { 114 | return [ 115 | EventType::ACCEPTED->value => ['event-data.event' => 'accepted'], 116 | EventType::CLICKED->value => ['event-data.event' => 'clicked'], 117 | EventType::COMPLAINED->value => ['event-data.event' => 'complained'], 118 | EventType::DELIVERED->value => ['event-data.event' => 'delivered'], 119 | EventType::HARD_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'permanent'], 120 | EventType::OPENED->value => ['event-data.event' => 'opened'], 121 | EventType::SOFT_BOUNCED->value => ['event-data.event' => 'failed', 'event-data.severity' => 'temporary'], 122 | EventType::UNSUBSCRIBED->value => ['event-data.event' => 'unsubscribed'], 123 | ]; 124 | } 125 | 126 | public function dataMapping(): array 127 | { 128 | return [ 129 | 'ip_address' => 'event-data.ip', 130 | 'platform' => 'event-data.client-info.device-type', 131 | 'os' => 'event-data.client-info.client-os', 132 | 'browser' => 'event-data.client-info.client-name', 133 | 'user_agent' => 'event-data.client-info.user-agent', 134 | 'city' => 'event-data.geolocation.city', 135 | 'country_code' => 'event-data.geolocation.country', 136 | 'link' => 'event-data.url', 137 | 'tag' => 'event-data.tags', 138 | ]; 139 | } 140 | 141 | public function unsuppressEmailAddress(string $address, ?int $stream_id = null): Response 142 | { 143 | $pendingRequest = Http::asJson() 144 | ->withBasicAuth('api', config('services.mailgun.secret')) 145 | ->baseUrl(config('services.mailgun.endpoint').'/v3/'); 146 | 147 | return $pendingRequest->delete(config('services.mailgun.domain').'/unsubscribes/'.$address); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Models/Mail.php: -------------------------------------------------------------------------------- 1 | sent() 46 | * @method static Builder delivered() 47 | * @method static Builder opened() 48 | * @method static Builder clicked() 49 | * @method static Builder softBounced() 50 | * @method static Builder hardBounced() 51 | * @method static Builder unsent() 52 | */ 53 | class Mail extends Model 54 | { 55 | use HasFactory, MassPrunable; 56 | 57 | protected $fillable = [ 58 | 'uuid', 59 | 'mailer', 60 | 'transport', 61 | 'stream_id', 62 | 'mail_class', 63 | 'subject', 64 | 'html', 65 | 'text', 66 | 'from', 67 | 'reply_to', 68 | 'to', 69 | 'cc', 70 | 'bcc', 71 | 'opens', 72 | 'clicks', 73 | 'tags', 74 | 'sent_at', 75 | 'resent_at', 76 | 'delivered_at', 77 | 'last_opened_at', 78 | 'last_clicked_at', 79 | 'complained_at', 80 | 'soft_bounced_at', 81 | 'hard_bounced_at', 82 | ]; 83 | 84 | protected $casts = [ 85 | 'id' => 'integer', 86 | 'uuid' => 'string', 87 | 'mailer' => 'string', 88 | 'transport' => 'string', 89 | 'stream_id' => 'string', 90 | 'subject' => 'string', 91 | 'from' => 'json', 92 | 'reply_to' => 'json', 93 | 'to' => 'json', 94 | 'cc' => 'json', 95 | 'bcc' => 'json', 96 | 'opens' => 'integer', 97 | 'clicks' => 'integer', 98 | 'tags' => 'json', 99 | 'sent_at' => 'datetime', 100 | 'resent_at' => 'datetime', 101 | 'accepted_at' => 'datetime', 102 | 'delivered_at' => 'datetime', 103 | 'last_opened_at' => 'datetime', 104 | 'last_clicked_at' => 'datetime', 105 | 'complained_at' => 'datetime', 106 | 'soft_bounced_at' => 'datetime', 107 | 'hard_bounced_at' => 'datetime', 108 | 'unsubscribed_at' => 'datetime', 109 | 'created_at' => 'datetime', 110 | 'updated_at' => 'datetime', 111 | ]; 112 | 113 | public function getTable() 114 | { 115 | return config('mails.database.tables.mails'); 116 | } 117 | 118 | protected static function booted(): void 119 | { 120 | static::created(function (Mail $mail): void { 121 | event(MailLogged::class, $mail); 122 | }); 123 | } 124 | 125 | protected static function newFactory(): Factory 126 | { 127 | return MailFactory::new(); 128 | } 129 | 130 | public function prunable(): Builder 131 | { 132 | $pruneAfter = config('mails.database.pruning.after', 30); 133 | 134 | return static::where('created_at', '<=', now()->subDays($pruneAfter)); 135 | } 136 | 137 | public function attachments(): HasMany 138 | { 139 | return $this->hasMany(config('mails.models.attachment')); 140 | } 141 | 142 | public function events(): HasMany 143 | { 144 | return $this->hasMany(config('mails.models.event'))->orderBy('occurred_at', 'desc'); 145 | } 146 | 147 | public function scopeResent(Builder $builder): Builder 148 | { 149 | return $builder->whereNotNull('resent_at'); 150 | } 151 | 152 | public function scopeDelivered(Builder $builder): Builder 153 | { 154 | return $builder->whereNotNull('delivered_at'); 155 | } 156 | 157 | public function scopeClicked(Builder $builder): Builder 158 | { 159 | return $builder->whereNotNull('last_clicked_at'); 160 | } 161 | 162 | public function scopeOpened(Builder $builder): Builder 163 | { 164 | return $builder->whereNotNull('last_opened_at'); 165 | } 166 | 167 | public function scopeComplained(Builder $builder): Builder 168 | { 169 | return $builder->whereNotNull('complained_at'); 170 | } 171 | 172 | public function scopeSoftBounced(Builder $builder): Builder 173 | { 174 | return $builder->whereNotNull('soft_bounced_at'); 175 | } 176 | 177 | public function scopeHardBounced(Builder $builder): Builder 178 | { 179 | return $builder->whereNotNull('hard_bounced_at'); 180 | } 181 | 182 | public function scopeBounced(Builder $builder): Builder 183 | { 184 | return $builder->where(function ($query): void { 185 | $query->whereNotNull('soft_bounced_at') 186 | ->orWhereNotNull('hard_bounced_at'); 187 | }); 188 | } 189 | 190 | public function scopeSent(Builder $builder): Builder 191 | { 192 | return $builder->whereNotNull('sent_at'); 193 | } 194 | 195 | public function scopeUnsent(Builder $builder): Builder 196 | { 197 | return $builder->whereNull('sent_at'); 198 | } 199 | 200 | protected function status(): Attribute 201 | { 202 | return Attribute::make(get: function () { 203 | if ($this->hard_bounced_at) { 204 | return __('Hard Bounced'); 205 | } 206 | if ($this->soft_bounced_at) { 207 | return __('Soft Bounced'); 208 | } 209 | if ($this->complained_at) { 210 | return __('Complained'); 211 | } 212 | if ($this->last_clicked_at) { 213 | return __('Clicked'); 214 | } 215 | if ($this->last_opened_at) { 216 | return __('Opened'); 217 | } 218 | if ($this->delivered_at) { 219 | return __('Delivered'); 220 | } 221 | if ($this->resent_at) { 222 | return __('Resent'); 223 | } 224 | if ($this->sent_at) { 225 | return __('Sent'); 226 | } 227 | 228 | return __('Unsent'); 229 | }); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keep track of all events on sent emails in Laravel and get notified when something is wrong 2 | 3 | [![Total Downloads](https://img.shields.io/packagist/dt/backstage/laravel-mails.svg?style=flat-square)](https://packagist.org/packages/backstage/laravel-mails) 4 | [![Tests](https://github.com/backstagephp/laravel-mails/actions/workflows/pest.yml/badge.svg?branch=main)](https://github.com/backstagephp/laravel-mails/actions/workflows/pest.yml) 5 | [![PHPStan](https://github.com/backstagephp/laravel-mails/actions/workflows/phpstan.yml/badge.svg?branch=main)](https://github.com/backstagephp/laravel-mails/actions/workflows/phpstan.yml) 6 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/backstagephp/laravel-mails) 7 | ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/backstage/laravel-mails) 8 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/backstage/laravel-mails.svg?style=flat-square)](https://packagist.org/packages/backstage/laravel-mails) 9 | 10 | ## Nice to meet you, we're [Backstage](https://backstagephp.com) 11 | 12 | Hi! We are a web development agency from Nijmegen in the Netherlands and we use Laravel for everything: advanced websites with a lot of bells and whitles and large web applications. 13 | 14 | ## Why this package 15 | 16 | Email as a protocol is very error prone. Succesfull email delivery is not guaranteed in any way, so it is best to monitor your email sending realtime. Using external services like Postmark or Mailgun, email gets better by offering things like logging and delivery feedback, but it still needs your attention and can fail silently but horendously. Therefore we created Laravel Mails that fills in all the gaps. 17 | 18 | Using Laravel we create packages to scratch a lot of our own itches, as we get to certain challenges working for our clients and on our projects. One of our problems in our 13 years of web development experience is customers that contact us about emails not getting delivered. 19 | 20 | Sometimes this happens because of a bug in code, but often times it's because of things going wrong you can't imagine before hand. If it can fail, it will fail. Using Murphy's law in full extend! And email is one of these types where this happens more than you like. 21 | 22 | As we got tired of the situation that a customer needs to call us, we want to know before the customer can notice it and contact us. Therefore we created this package: to log all events happening with our sent emails and to get automatically notified using Discord (or Slack, Telegram) when there are problems on the horizon. 23 | 24 | ## Features 25 | 26 | Laravel Mails can collect everything you might want to track about the mails that has been sent by your Laravel app. Common use cases are provided in this package: 27 | 28 | - Log all sent emails, attachments and events with only specific attributes 29 | - Works currently for popular email service providers Postmark and Mailgun 30 | - Collect feedback about the delivery status from email providers using webhooks 31 | - Get quickly and automatically notified when email hard/soft bounces or the bouncerate goes too high 32 | - Prune all logged emails periodically to keep the database nice and slim 33 | - Resend logged emails to another recipient 34 | - View all sent emails in the browser using complementary package [Filament Mails](https://github.com/backstagephp/filament-mails) 35 | 36 | ## Upcoming features 37 | 38 | - We can write drivers for popular email service providers like Resend, SendGrid, Amazon SES and Mailtrap. 39 | - Relate emails being send in Laravel directly to Eloquent models, for example the order confirmation email attached to an Order model. 40 | 41 | ## Looking for a UI? We've got your back: [Filament Mails](https://github.com/backstagephp/filament-mails) 42 | 43 | We created a Laravel [Filament](https://filamentphp.com) plugin called [Filament Mails](https://github.com/backstagephp/filament-mails) to easily view all data collected by this Laravel Mails package. 44 | 45 | It can show all information about the emails and events in a beautiful UI: 46 | 47 | ![Filament Mails](https://raw.githubusercontent.com/backstagephp/filament-mails/main/docs/mails-list.png) 48 | 49 | ## Installation 50 | 51 | First install the package via composer: 52 | 53 | ```bash 54 | composer require backstage/laravel-mails 55 | ``` 56 | 57 | Then you can publish and run the migrations with: 58 | 59 | ```bash 60 | php artisan vendor:publish --tag="mails-migrations" 61 | php artisan migrate 62 | ``` 63 | 64 | Add the API key of your email service provider to the `config/services.php` file in your Laravel project, currently we only support Postmark and Mailgun: 65 | 66 | ```php 67 | 'postmark' => [ 68 | 'token' => env('POSTMARK_TOKEN'), 69 | ], 70 | 71 | 'mailgun' => [ 72 | 'domain' => env('MAILGUN_DOMAIN'), 73 | 'secret' => env('MAILGUN_SECRET'), 74 | 'webhook_signing_key' => env('MAILGUN_WEBHOOK_SIGNING_KEY'), 75 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 76 | 'scheme' => 'https', 77 | ] 78 | ``` 79 | 80 | When done, run this command with the slug of your service provider: 81 | 82 | ```bash 83 | php artisan mail:webhooks [service] // where [service] is your provider, e.g. postmark or mailgun 84 | ``` 85 | 86 | And for changing the configuration you can publish the config file with: 87 | 88 | ```bash 89 | php artisan vendor:publish --tag="mails-config" 90 | ``` 91 | 92 | This is the contents of the published config file: 93 | 94 | ```php 95 | // Eloquent model to use for sent emails 96 | 97 | 'models' => [ 98 | 'mail' => Mail::class, 99 | 'event' => MailEvent::class, 100 | 'attachment' => MailAttachment::class, 101 | ], 102 | 103 | // Table names for saving sent emails and polymorphic relations to database 104 | 105 | 'database' => [ 106 | 'tables' => [ 107 | 'mails' => 'mails', 108 | 'attachments' => 'mail_attachments', 109 | 'events' => 'mail_events', 110 | 'polymorph' => 'mailables', 111 | ], 112 | 113 | 'pruning' => [ 114 | 'enabled' => true, 115 | 'after' => 30, // days 116 | ], 117 | ], 118 | 119 | 'headers' => [ 120 | 'uuid' => 'X-Mails-UUID', 121 | 122 | 'associate' => 'X-Mails-Associated-Models', 123 | ], 124 | 125 | 'webhooks' => [ 126 | 'routes' => [ 127 | 'prefix' => 'webhooks/mails', 128 | ], 129 | 130 | 'queue' => env('MAILS_QUEUE_WEBHOOKS', false), 131 | ], 132 | 133 | // Logging mails 134 | 'logging' => [ 135 | 136 | // Enable logging of all sent mails to database 137 | 138 | 'enabled' => env('MAILS_LOGGING_ENABLED', true), 139 | 140 | // Specify attributes to log in database 141 | 142 | 'attributes' => [ 143 | 'subject', 144 | 'from', 145 | 'to', 146 | 'reply_to', 147 | 'cc', 148 | 'bcc', 149 | 'html', 150 | 'text', 151 | ], 152 | 153 | // Encrypt all attributes saved to database 154 | 155 | 'encrypted' => env('MAILS_ENCRYPTED', true), 156 | 157 | // Track following events using webhooks from email provider 158 | 159 | 'tracking' => [ 160 | 'bounces' => true, 161 | 'clicks' => true, 162 | 'complaints' => true, 163 | 'deliveries' => true, 164 | 'opens' => true, 165 | ], 166 | 167 | // Enable saving mail attachments to disk 168 | 169 | 'attachments' => [ 170 | 'enabled' => env('MAILS_LOGGING_ATTACHMENTS_ENABLED', true), 171 | 'disk' => env('FILESYSTEM_DISK', 'local'), 172 | 'root' => 'mails/attachments', 173 | ], 174 | ], 175 | 176 | // Notifications for important mail events 177 | 178 | 'notifications' => [ 179 | 'mail' => [ 180 | 'to' => ['test@example.com'], 181 | ], 182 | 183 | 'discord' => [ 184 | // 'to' => ['1234567890'], 185 | ], 186 | 187 | 'slack' => [ 188 | // 'to' => ['https://hooks.slack.com/services/...'], 189 | ], 190 | 191 | 'telegram' => [ 192 | // 'to' => ['1234567890'], 193 | ], 194 | ], 195 | 196 | 'events' => [ 197 | 'soft_bounced' => [ 198 | 'notify' => ['mail'], 199 | ], 200 | 201 | 'hard_bounced' => [ 202 | 'notify' => ['mail'], 203 | ], 204 | 205 | 'bouncerate' => [ 206 | 'notify' => [], 207 | 208 | 'retain' => 30, // days 209 | 210 | 'treshold' => 1, // % 211 | ], 212 | 213 | 'deliveryrate' => [ 214 | 'treshold' => 99, 215 | ], 216 | 217 | 'complained' => [ 218 | // 219 | ], 220 | 221 | 'unsent' => [ 222 | // 223 | ], 224 | ] 225 | ``` 226 | 227 | ## Usage 228 | 229 | ### Logging 230 | 231 | When you send emails within Laravel using the `Mail` Facade or using a `Mailable`, Laravel Mails will log the email sending and all events that are incoming from your email service provider. 232 | 233 | ### Relate emails to Eloquent models 234 | 235 | ... 236 | 237 | ### Resend a logged email 238 | 239 | ... 240 | 241 | ### Get notified of important events such as bounces, high bounce rate or spam complaints 242 | 243 | ... 244 | 245 | ### Prune logged emails 246 | 247 | ... 248 | 249 | ## Events 250 | 251 | Depending on the mail provider, we send these events comming in from the webhooks of the email service provider. 252 | 253 | ```php 254 | Backstage\Mails\Events\MailAccepted::class, 255 | Backstage\Mails\Events\MailClicked::class, 256 | Backstage\Mails\Events\MailComplained::class, 257 | Backstage\Mails\Events\MailDelivered::class, 258 | Backstage\Mails\Events\MailEvent::class, 259 | Backstage\Mails\Events\MailEventLogged::class, 260 | Backstage\Mails\Events\MailHardBounced::class, 261 | Backstage\Mails\Events\MailOpened::class, 262 | Backstage\Mails\Events\MailResent::class, 263 | Backstage\Mails\Events\MailSoftBounced::class, 264 | Backstage\Mails\Events\MailUnsubscribed::class, 265 | ``` 266 | 267 | ## Testing 268 | 269 | ```bash 270 | composer test 271 | ``` 272 | 273 | ## Changelog 274 | 275 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 276 | 277 | ## Contributing 278 | 279 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 280 | 281 | ## Security Vulnerabilities 282 | 283 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 284 | 285 | ## Credits 286 | 287 | - [Mark van Eijk](https://github.com/markvaneijk) 288 | - [All Contributors](../../contributors) 289 | 290 | ## License 291 | 292 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 293 | -------------------------------------------------------------------------------- /src/Drivers/PostmarkDriver.php: -------------------------------------------------------------------------------- 1 | [ 22 | 'Enabled' => (bool) $trackingConfig['opens'], 23 | 'PostFirstOpenOnly' => false, 24 | ], 25 | 'Click' => [ 26 | 'Enabled' => (bool) $trackingConfig['clicks'], 27 | ], 28 | 'Delivery' => [ 29 | 'Enabled' => (bool) $trackingConfig['deliveries'], 30 | ], 31 | 'Bounce' => [ 32 | 'Enabled' => (bool) $trackingConfig['bounces'], 33 | 'IncludeContent' => (bool) $trackingConfig['bounces'], 34 | ], 35 | 'SpamComplaint' => [ 36 | 'Enabled' => (bool) $trackingConfig['complaints'], 37 | 'IncludeContent' => (bool) $trackingConfig['complaints'], 38 | ], 39 | 'SubscriptionChange' => [ 40 | 'Enabled' => (bool) $trackingConfig['unsubscribes'], 41 | ], 42 | ]; 43 | 44 | $webhookUrl = URL::signedRoute('mails.webhook', ['provider' => Provider::POSTMARK]); 45 | 46 | $token = (string) config('services.postmark.token'); 47 | 48 | $headers = [ 49 | 'Accept' => 'application/json', 50 | 'X-Postmark-Server-Token' => $token, 51 | ]; 52 | 53 | $broadcastStream = collect(Http::withHeaders($headers)->get('https://api.postmarkapp.com/message-streams')['MessageStreams'] ?? []); 54 | 55 | if ($broadcastStream->where('ID', 'broadcast')->count() === 0) { 56 | Http::withHeaders($headers)->post('https://api.postmarkapp.com/message-streams', [ 57 | 'ID' => 'broadcast', 58 | 'Name' => 'Broadcasts', 59 | 'Description' => 'Default Broadcast Stream', 60 | ]); 61 | } else { 62 | $components->info('Broadcast stream already exists'); 63 | } 64 | 65 | $outboundWebhooks = collect(Http::withHeaders($headers)->get('https://api.postmarkapp.com/webhooks?MessageStream=outbound')['Webhooks'] ?? []); 66 | 67 | if ($outboundWebhooks->where('Url', $webhookUrl)->count() === 0) { 68 | $response = Http::withHeaders($headers)->post('https://api.postmarkapp.com/webhooks?MessageStream=outbound', [ 69 | 'Url' => $webhookUrl, 70 | 'Triggers' => $triggers, 71 | ]); 72 | 73 | if ($response->ok()) { 74 | $components->info('Created Postmark webhook for outbound stream'); 75 | } else { 76 | $components->error('Failed to create Postmark webhook for outbound stream'); 77 | } 78 | } else { 79 | $components->info('Outbound webhook already exists'); 80 | } 81 | 82 | $broadcastWebhooks = collect(Http::withHeaders($headers)->get('https://api.postmarkapp.com/webhooks?MessageStream=broadcast')['Webhooks'] ?? []); 83 | 84 | if ($broadcastWebhooks->where('Url', $webhookUrl)->count() === 0) { 85 | $response = Http::withHeaders($headers)->post('https://api.postmarkapp.com/webhooks?MessageStream=broadcast', [ 86 | 'Url' => $webhookUrl, 87 | 'MessageStream' => 'broadcast', 88 | 'Triggers' => $triggers, 89 | ]); 90 | 91 | if ($response->ok()) { 92 | $components->info('Created Postmark webhook for broadcast stream'); 93 | } else { 94 | $components->error('Failed to create Postmark webhook for broadcast stream'); 95 | } 96 | } else { 97 | $components->info('Broadcast webhook already exists'); 98 | } 99 | } 100 | 101 | public function verifyWebhookSignature(array $payload): bool 102 | { 103 | return true; 104 | } 105 | 106 | public function attachUuidToMail(MessageSending $messageSending, string $uuid): MessageSending 107 | { 108 | $messageSending->message->getHeaders()->addTextHeader('X-PM-Metadata-'.config('mails.headers.uuid'), $uuid); 109 | 110 | return $messageSending; 111 | } 112 | 113 | public function getUuidFromPayload(array $payload): ?string 114 | { 115 | return $payload['Metadata'][$this->uuidHeaderName] ?? 116 | $payload['Metadata'][strtolower($this->uuidHeaderName)] ?? 117 | $payload['Metadata'][strtoupper($this->uuidHeaderName)] ?? 118 | null; 119 | } 120 | 121 | protected function getTimestampFromPayload(array $payload): string 122 | { 123 | return $payload['DeliveredAt'] ?? $payload['BouncedAt'] ?? $payload['ReceivedAt'] ?? now(); 124 | } 125 | 126 | public function eventMapping(): array 127 | { 128 | return [ 129 | EventType::CLICKED->value => ['RecordType' => 'Click'], 130 | EventType::COMPLAINED->value => ['RecordType' => 'SpamComplaint'], 131 | EventType::DELIVERED->value => ['RecordType' => 'Delivery'], 132 | EventType::HARD_BOUNCED->value => ['RecordType' => 'Bounce', 'Type' => 'HardBounce'], 133 | EventType::OPENED->value => ['RecordType' => 'Open'], 134 | EventType::SOFT_BOUNCED->value => ['RecordType' => 'Bounce', 'Type' => 'SoftBounce'], 135 | EventType::UNSUBSCRIBED->value => ['RecordType' => 'SubscriptionChange'], 136 | 137 | // Others 138 | EventType::TRANSIENT->value => ['RecordType' => 'Transient'], 139 | EventType::UNSUBSCRIBE->value => ['RecordType' => 'SubscriptionChange', 'Type' => 'Unsubscribe'], 140 | EventType::SUBSCRIBE->value => ['RecordType' => 'SubscriptionChange', 'Type' => 'Subscribe'], 141 | EventType::AUTO_RESPONDER->value => ['RecordType' => 'AutoResponder'], 142 | EventType::ADDRESS_CHANGE->value => ['RecordType' => 'AddressChange'], 143 | EventType::DNS_ERROR->value => ['RecordType' => 'DNSError'], 144 | EventType::SPAM_NOTIFICATION->value => ['RecordType' => 'SpamNotification'], 145 | EventType::OPEN_RELAY_TEST->value => ['RecordType' => 'OpenRelayTest'], 146 | EventType::SOFT_BOUNCE->value => ['RecordType' => 'SoftBounce'], 147 | EventType::VIRUS_NOTIFICATION->value => ['RecordType' => 'VirusNotification'], 148 | EventType::CHALLENGE_VERIFICATION->value => ['RecordType' => 'ChallengeVerification'], 149 | EventType::BAD_EMAIL_ADDRESS->value => ['RecordType' => 'BadEmailAddress'], 150 | EventType::SPAM_COMPLAINT->value => ['RecordType' => 'SpamComplaint'], 151 | EventType::MANUALLY_DEACTIVATED->value => ['RecordType' => 'ManuallyDeactivated'], 152 | EventType::UNCONFIRMED->value => ['RecordType' => 'Unconfirmed'], 153 | EventType::BLOCKED->value => ['RecordType' => 'Blocked'], 154 | EventType::SMTP_API_ERROR->value => ['RecordType' => 'SMTPAPIError'], 155 | EventType::INBOUND_ERROR->value => ['RecordType' => 'InboundError'], 156 | EventType::DMARC_POLICY->value => ['RecordType' => 'DMARCPolicy'], 157 | EventType::TEMPLATE_RENDERING_FAILED->value => ['RecordType' => 'TemplateRenderingFailed'], 158 | EventType::UNKNOWN->value => ['RecordType' => 'Unknown'], 159 | ]; 160 | } 161 | 162 | public function dataMapping(): array 163 | { 164 | return [ 165 | 'browser' => 'Client.Family', 166 | 'city' => 'City', 167 | 'country_code' => 'Geo.CountryISOCode', 168 | 'ip_address' => 'Geo.IP', 169 | 'link' => 'OriginalLink', 170 | 'os' => 'OS.Family', 171 | 'platform' => 'Platform', 172 | 'tag' => 'Tag', 173 | 'user_agent' => 'UserAgent', 174 | ]; 175 | } 176 | 177 | public function unsuppressEmailAddress(string $address, ?int $stream_id = null): Response 178 | { 179 | $pendingRequest = Http::asJson() 180 | ->withHeaders([ 181 | 'X-Postmark-Server-Token' => config('services.postmark.token'), 182 | ]) 183 | ->baseUrl('https://api.postmarkapp.com/'); 184 | 185 | return $pendingRequest->post('message-streams/'.$stream_id.'/suppressions/delete', [ 186 | 'Suppressions' => [['emailAddress' => $address]], 187 | ]); 188 | } 189 | 190 | public function transient(Mail $mail, string $timestamp): void 191 | { 192 | $this->softBounced($mail, $timestamp); 193 | } 194 | 195 | public function unsubscribe(Mail $mail, string $timestamp): void 196 | { 197 | $this->unsubscribed($mail, $timestamp); 198 | } 199 | 200 | public function dnsError(Mail $mail, string $timestamp): void 201 | { 202 | $this->softBounced($mail, $timestamp); 203 | } 204 | 205 | public function spamNotification(Mail $mail, string $timestamp): void 206 | { 207 | $this->complained($mail, $timestamp); 208 | } 209 | 210 | public function softBounce(Mail $mail, string $timestamp): void 211 | { 212 | $this->softBounced($mail, $timestamp); 213 | } 214 | 215 | public function virusNotification(Mail $mail, string $timestamp): void 216 | { 217 | $this->complained($mail, $timestamp); 218 | } 219 | 220 | public function challengeVerification(Mail $mail, string $timestamp): void 221 | { 222 | $this->softBounced($mail, $timestamp); 223 | } 224 | 225 | public function badEmailAddress(Mail $mail, string $timestamp): void 226 | { 227 | $this->hardBounced($mail, $timestamp); 228 | } 229 | 230 | public function spamComplaint(Mail $mail, string $timestamp): void 231 | { 232 | $this->complained($mail, $timestamp); 233 | } 234 | 235 | public function blocked(Mail $mail, string $timestamp): void 236 | { 237 | $this->hardBounced($mail, $timestamp); 238 | } 239 | 240 | public function smtpApiError(Mail $mail, string $timestamp): void 241 | { 242 | $this->hardBounced($mail, $timestamp); 243 | } 244 | 245 | public function dmarcPolicy(Mail $mail, string $timestamp): void 246 | { 247 | $this->hardBounced($mail, $timestamp); 248 | } 249 | 250 | public function templateRenderingFailed(Mail $mail, string $timestamp): void 251 | { 252 | $this->hardBounced($mail, $timestamp); 253 | } 254 | } 255 | --------------------------------------------------------------------------------