#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.

This commit is contained in:
Andy Heathershaw 2019-07-14 12:13:58 +01:00
parent bfbf740810
commit 7418438d63
14 changed files with 464 additions and 92 deletions

View File

@ -0,0 +1,141 @@
<?php
namespace App\Console\Commands;
use App\EmailLog;
use App\Facade\UserConfig;
use App\Http\Middleware\GlobalConfiguration;
use Illuminate\Console\Command;
use Illuminate\Mail\Mailer;
use Illuminate\Mail\Message;
use Illuminate\Support\Facades\Mail;
class SendEmailsCommand extends Command
{
const MAX_EMAILS_PER_BATCH = 100;
const MAX_NUMBER_ATTEMPTS = 5;
const SECONDS_TO_SLEEP = 30;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bt-queue:send-emails {--poll}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends e-mails queued in the database.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
GlobalConfiguration::updateMailConfig();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if (!UserConfig::get('queue_emails'))
{
$this->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);
}
}
}
}

26
app/EmailLog.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class EmailLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'sender_user_id',
'queued_at',
'sent_at',
'sender_name',
'sender_address',
'to_addresses',
'cc_addresses',
'subject',
'body_plain',
'body_html'
];
}

View File

@ -23,6 +23,44 @@ class GlobalConfiguration
*/
protected $app;
public static function updateMailConfig()
{
/** @var Mailer $mailer */
$mailer = 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'));
}
/**
* 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'));
}
}

41
app/Mail/MailableBase.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Mail;
use App\EmailLog;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
abstract class MailableBase extends Mailable
{
public function buildEmailLog()
{
// Build the e-mail
$this->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()
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Mail;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ResetMyPassword extends MailableBase
{
use Queueable, SerializesModels;
private $token;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, $token)
{
$this->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
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Notifications;
use App\EmailLog;
class EmailDatabaseWriterChannelBase
{
protected function writeToTable(EmailLog $logEntry, $shouldQueue = false)
{
if ($shouldQueue)
{
$logEntry->queued_at = new \DateTime();
}
else
{
$logEntry->sent_at = new \DateTime();
}
$logEntry->save();
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Notifications;
use App\EmailLog;
use Illuminate\Notifications\Notification;
class QueueEmailDatabaseChannel extends EmailDatabaseWriterChannelBase
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return void
*/
public function send($notifiable, Notification $notification)
{
/** @var EmailLog $logEntry */
$logEntry = $notification->toQueueEmailDatabase($notifiable);
$this->writeToTable($logEntry, true);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Notifications;
use App\EmailLog;
use Illuminate\Notifications\Notification;
class SentEmailDatabaseChannel extends EmailDatabaseWriterChannelBase
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return void
*/
public function send($notifiable, Notification $notification)
{
/** @var EmailLog $logEntry */
$logEntry = $notification->toDatabaseWriter($notifiable);
$this->writeToTable($logEntry, false);
}
}

View File

@ -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",

View File

@ -1,36 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('background_jobs', function (Blueprint $table) {
$table->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');
}
}

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateEmailLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('email_logs', function (Blueprint $table) {
$table->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');
}
}

View File

@ -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',

View File

@ -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')<br/>
{{ UserConfig::get('app_name') }}<br/>
<a href="{{ route('home') }}">{{ route('home') }}</a>
@endcomponent