From 7418438d6353c229c484053d8a1ebb6ae4896a5f Mon Sep 17 00:00:00 2001 From: Andy Heathershaw Date: Sun, 14 Jul 2019 12:13:58 +0100 Subject: [PATCH] #123: The framework of sending e-mails using Mailables but queued in the database is now there. Password reset e-mails are now the first ones being sent using the queue. --- app/Console/Commands/SendEmailsCommand.php | 141 ++++++++++++++++++ app/EmailLog.php | 26 ++++ app/Http/Middleware/GlobalConfiguration.php | 76 +++++----- app/Mail/MailableBase.php | 41 +++++ app/Mail/ResetMyPassword.php | 48 ++++++ .../EmailDatabaseWriterChannelBase.php | 22 +++ .../QueueEmailDatabaseChannel.php | 24 +++ app/Notifications/ResetPassword.php | 47 +++--- .../SentEmailDatabaseChannel.php | 24 +++ composer.json | 1 + .../2019_07_13_203923_create_jobs_table.php | 36 ----- ...9_07_14_100811_create_email_logs_table.php | 47 ++++++ resources/lang/en/email.php | 4 + .../base/email/reset_my_password.blade.php | 19 +++ 14 files changed, 464 insertions(+), 92 deletions(-) create mode 100644 app/Console/Commands/SendEmailsCommand.php create mode 100644 app/EmailLog.php create mode 100644 app/Mail/MailableBase.php create mode 100644 app/Mail/ResetMyPassword.php create mode 100644 app/Notifications/EmailDatabaseWriterChannelBase.php create mode 100644 app/Notifications/QueueEmailDatabaseChannel.php create mode 100644 app/Notifications/SentEmailDatabaseChannel.php delete mode 100644 database/migrations/2019_07_13_203923_create_jobs_table.php create mode 100644 database/migrations/2019_07_14_100811_create_email_logs_table.php create mode 100644 resources/views/themes/base/email/reset_my_password.blade.php diff --git a/app/Console/Commands/SendEmailsCommand.php b/app/Console/Commands/SendEmailsCommand.php new file mode 100644 index 0000000..153a7eb --- /dev/null +++ b/app/Console/Commands/SendEmailsCommand.php @@ -0,0 +1,141 @@ +output->error('E-mail queueing is not enabled. E-mails are being sent immediately.'); + } + + $this->output->writeln('E-mail queue runner started'); + + while (true) + { + $emailsToSend = EmailLog::where([ + ['sent_at', null], + ['number_attempts', '<', self::MAX_NUMBER_ATTEMPTS] + ])->limit(self::MAX_EMAILS_PER_BATCH)->get(); + + $this->output->writeln(sprintf( + '%d e-mail%s to send', + $emailsToSend->count(), + $emailsToSend->count() == 1 ? '' : 's' + )); + + /** @var EmailLog $emailToSend */ + foreach ($emailsToSend as $emailToSend) + { + $this->sendEmail($emailToSend); + } + + if (!$this->option('poll')) + { + exit(); + } + + sleep(self::SECONDS_TO_SLEEP); + } + } + + private function sendEmail(EmailLog $emailLog) + { + $this->output->writeln(sprintf('Sending message with subject \'%s\'', $emailLog->subject)); + + try + { + app('mailer')->send( + [], + [], + function (Message $message) use ($emailLog) + { + $message->setFrom($emailLog->sender_address, $emailLog->sender_name); + $message->setSubject($emailLog->subject); + + $this->addAddresses($emailLog->to_addresses, $message, 'To'); + $this->addAddresses($emailLog->cc_addresses, $message, 'Cc'); + $this->addAddresses($emailLog->bcc_addresses, $message, 'Bcc'); + + $message->addPart($emailLog->body_plain, 'text/plain'); + $message->setBody($emailLog->body_html, 'text/html'); + } + ); + + $emailLog->sent_at = new \DateTime(); + + $this->output->writeln('Send completed'); + } + catch (\Exception $ex) + { + $this->output->error(sprintf('Send failed: %s', $ex->getMessage())); + } + finally + { + $emailLog->number_attempts++; + $emailLog->save(); + } + } + + private function addAddresses($dbFieldData, Message $message, $property) + { + $decoded = json_decode($dbFieldData); + + if (is_array($decoded)) + { + foreach ($decoded as $addressInfo) + { + $this->output->writeln(sprintf('Adding %s address: \'"%s" <%s>\'', $property, $addressInfo->name, $addressInfo->address)); + + $message->{"set{$property}"}($addressInfo->address, $addressInfo->name); + } + } + } +} diff --git a/app/EmailLog.php b/app/EmailLog.php new file mode 100644 index 0000000..fb2c9c6 --- /dev/null +++ b/app/EmailLog.php @@ -0,0 +1,26 @@ +getSwiftMailer(); + + /** @var \Swift_SmtpTransport $transport */ + $transport = $swiftMailer->getTransport(); + $transport->setHost(UserConfig::get('smtp_server')); + $transport->setPort(intval(UserConfig::get('smtp_port'))); + + $username = UserConfig::get('smtp_username'); + if (!is_null($username)) + { + $transport->setUsername($username); + } + + $password = UserConfig::get('smtp_password'); + if (!is_null($password)) + { + try + { + $transport->setPassword(decrypt($password)); + } + catch (DecryptException $ex) + { + // Unable to decrypt the password - presumably the app's key has changed + } + } + + if (UserConfig::get('smtp_encryption')) + { + $transport->setEncryption('tls'); + } + + $mailer->alwaysFrom(UserConfig::get('sender_address'), UserConfig::get('sender_name')); + } + /** * Create a new middleware instance. * @@ -170,42 +208,4 @@ class GlobalConfiguration View::share('app_version', $version); View::share('app_version_url', $version); } - - private function updateMailConfig() - { - /** @var Mailer $mailer */ - $mailer = $this->app->mailer; - $swiftMailer = $mailer->getSwiftMailer(); - - /** @var \Swift_SmtpTransport $transport */ - $transport = $swiftMailer->getTransport(); - $transport->setHost(UserConfig::get('smtp_server')); - $transport->setPort(intval(UserConfig::get('smtp_port'))); - - $username = UserConfig::get('smtp_username'); - if (!is_null($username)) - { - $transport->setUsername($username); - } - - $password = UserConfig::get('smtp_password'); - if (!is_null($password)) - { - try - { - $transport->setPassword(decrypt($password)); - } - catch (DecryptException $ex) - { - // Unable to decrypt the password - presumably the app's key has changed - } - } - - if (UserConfig::get('smtp_encryption')) - { - $transport->setEncryption('tls'); - } - - $mailer->alwaysFrom(UserConfig::get('sender_address'), UserConfig::get('sender_name')); - } } \ No newline at end of file diff --git a/app/Mail/MailableBase.php b/app/Mail/MailableBase.php new file mode 100644 index 0000000..df85fb0 --- /dev/null +++ b/app/Mail/MailableBase.php @@ -0,0 +1,41 @@ +build(); + + // Get the current user for the ID + $currentUser = Auth::user(); + + // Build the body so we can use it as a string + $bodies = $this->buildView(); + + /** @var HtmlString $html */ + $html = $bodies['html']; + + /** @var HtmlString $text */ + $text = $bodies['text']; + + return new EmailLog([ + 'sender_user_id' => !is_null($currentUser) ? $currentUser->id : null, + 'sender_name' => $this->from[0]['name'], + 'sender_address' => $this->from[0]['address'], + 'to_addresses' => json_encode($this->to), + 'cc_addresses' => json_encode($this->cc), + 'bcc_addresses' => json_encode($this->bcc), + 'subject' => $this->subject, + 'body_plain' => $text->toHtml(), + 'body_html' => $html->toHtml() + ]); + } +} \ No newline at end of file diff --git a/app/Mail/ResetMyPassword.php b/app/Mail/ResetMyPassword.php new file mode 100644 index 0000000..2624434 --- /dev/null +++ b/app/Mail/ResetMyPassword.php @@ -0,0 +1,48 @@ +user = $user; + $this->token = $token; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $subject = trans('email.reset_my_password_subject', ['app_name' => UserConfig::get('app_name')]); + + return $this + ->subject($subject) + ->markdown(Theme::viewName('email.reset_my_password')) + ->with([ + 'subject' => $subject, + 'token' => $this->token, + 'user' => $this->user + ]); + } +} \ No newline at end of file diff --git a/app/Notifications/EmailDatabaseWriterChannelBase.php b/app/Notifications/EmailDatabaseWriterChannelBase.php new file mode 100644 index 0000000..1d9c8d5 --- /dev/null +++ b/app/Notifications/EmailDatabaseWriterChannelBase.php @@ -0,0 +1,22 @@ +queued_at = new \DateTime(); + } + else + { + $logEntry->sent_at = new \DateTime(); + } + + $logEntry->save(); + } +} \ No newline at end of file diff --git a/app/Notifications/QueueEmailDatabaseChannel.php b/app/Notifications/QueueEmailDatabaseChannel.php new file mode 100644 index 0000000..b4ae877 --- /dev/null +++ b/app/Notifications/QueueEmailDatabaseChannel.php @@ -0,0 +1,24 @@ +toQueueEmailDatabase($notifiable); + + $this->writeToTable($logEntry, true); + } +} \ No newline at end of file diff --git a/app/Notifications/ResetPassword.php b/app/Notifications/ResetPassword.php index 68f7ecf..8a4e100 100644 --- a/app/Notifications/ResetPassword.php +++ b/app/Notifications/ResetPassword.php @@ -2,7 +2,11 @@ namespace App\Notifications; +use App\Facade\UserConfig; +use App\Mail\MailableBase; +use App\Mail\ResetMyPassword; use Illuminate\Bus\Queueable; +use Illuminate\Mail\Mailable; use Illuminate\Notifications\Notification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; @@ -37,33 +41,40 @@ class ResetPassword extends Notification */ public function via($notifiable) { - return ['mail']; + $drivers = []; + + if (UserConfig::get('queue_emails')) + { + $drivers[] = QueueEmailDatabaseChannel::class; + } + else + { + $drivers[] = 'mail'; + $drivers[] = SentEmailDatabaseChannel::class; + } + + return $drivers; + } + + public function toQueueEmailDatabase($notifiable) + { + return $this->toMail($notifiable)->buildEmailLog(); } /** * Get the mail representation of the notification. * * @param mixed $notifiable - * @return \Illuminate\Notifications\Messages\MailMessage + * @return \Illuminate\Notifications\Messages\MailMessage|MailableBase */ public function toMail($notifiable) { - return (new MailMessage) - ->line('You are receiving this email because we received a password reset request for your account.') - ->action('Reset Password', route('password.reset', $this->token, true)) - ->line('If you did not request a password reset, no further action is required.'); - } + $notification = new ResetMyPassword($notifiable, $this->token); - /** - * Get the array representation of the notification. - * - * @param mixed $notifiable - * @return array - */ - public function toArray($notifiable) - { - return [ - // - ]; + // Set to and from properties accordingly + $notification->from(UserConfig::get('sender_address'), UserConfig::get('sender_name')); + $notification->to($notifiable->email, $notifiable->name); + + return $notification; } } diff --git a/app/Notifications/SentEmailDatabaseChannel.php b/app/Notifications/SentEmailDatabaseChannel.php new file mode 100644 index 0000000..851cb15 --- /dev/null +++ b/app/Notifications/SentEmailDatabaseChannel.php @@ -0,0 +1,24 @@ +toDatabaseWriter($notifiable); + + $this->writeToTable($logEntry, false); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 1de1171..1255b7c 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "type": "project", "require": { "php": ">=7.0.0", + "ext-json": "*", "laravel/framework": "5.5.*", "rackspace/php-opencloud": "^1.16", "doctrine/dbal": "^2.5", diff --git a/database/migrations/2019_07_13_203923_create_jobs_table.php b/database/migrations/2019_07_13_203923_create_jobs_table.php deleted file mode 100644 index 8533de5..0000000 --- a/database/migrations/2019_07_13_203923_create_jobs_table.php +++ /dev/null @@ -1,36 +0,0 @@ -bigIncrements('id'); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('background_jobs'); - } -} diff --git a/database/migrations/2019_07_14_100811_create_email_logs_table.php b/database/migrations/2019_07_14_100811_create_email_logs_table.php new file mode 100644 index 0000000..e02f5b1 --- /dev/null +++ b/database/migrations/2019_07_14_100811_create_email_logs_table.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->unsignedInteger('sender_user_id')->nullable(true); + $table->dateTime('queued_at')->nullable(true); + $table->dateTime('sent_at')->nullable(true); + $table->string('sender_name'); + $table->string('sender_address'); + $table->text('to_addresses'); + $table->text('cc_addresses')->nullable(true); + $table->text('bcc_addresses')->nullable(true); + $table->string('subject'); + $table->longText('body_plain'); + $table->longText('body_html'); + $table->integer('number_attempts')->default(0); + $table->timestamps(); + + $table->foreign('sender_user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('email_logs'); + } +} diff --git a/resources/lang/en/email.php b/resources/lang/en/email.php index 5842358..b5b87c0 100644 --- a/resources/lang/en/email.php +++ b/resources/lang/en/email.php @@ -30,6 +30,10 @@ return [ 'photo_comment_replied_to_p1' => 'A reply to your comment has been posted in the :album_name album.', 'photo_comment_replied_to_p2' => 'Click the button below to view the photo\'s page to see the reply to your comment.', 'photo_comment_replied_to_subject' => 'A reply to your comment was posted in :album_name', + 'reset_my_password_action' => 'Reset Password', + 'reset_my_password_p1' => 'You are receiving this email because we received a password reset request for your account.', + 'reset_my_password_p2' => 'If you did not request a password reset, no further action is required.', + 'reset_my_password_subject' => 'Your password reset link for :app_name', 'user_self_activated_p1' => 'A new user has been created and activated at :app_name. The user\'s details are shown below.', 'user_self_activated_p2' => 'Name: :user_name', 'user_self_activated_p3' => 'E-mail address: :email_address', diff --git a/resources/views/themes/base/email/reset_my_password.blade.php b/resources/views/themes/base/email/reset_my_password.blade.php new file mode 100644 index 0000000..1b3f39b --- /dev/null +++ b/resources/views/themes/base/email/reset_my_password.blade.php @@ -0,0 +1,19 @@ +@component('mail::message') +@lang('email.generic_intro', ['user_name' => $user->name]) + + +@lang('email.reset_my_password_p1') + + +@component('mail::button', ['url' => route('password.reset', $token, true), 'color' => 'blue']) + @lang('email.reset_my_password_action') +@endcomponent + + +@lang('email.reset_my_password_p2') + + +@lang('email.generic_regards')
+{{ UserConfig::get('app_name') }}
+{{ route('home') }} +@endcomponent \ No newline at end of file