diff --git a/database/migrations/7_add_tries_to_logs_table.php b/database/migrations/7_add_tries_to_logs_table.php new file mode 100644 index 0000000..c3dce56 --- /dev/null +++ b/database/migrations/7_add_tries_to_logs_table.php @@ -0,0 +1,35 @@ +unsignedSmallInteger('tries')->default(0); + $table->timestamp('last_try_at')->nullable(); + }); + + DB::table('logs')->whereNot('status', LogStatus::Pending->value)->update([ + 'last_try_at' => DB::raw('created_at'), + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logs', function (Blueprint $table) { + $table->dropColumn('tries'); + $table->dropColumn('last_try_at'); + }); + } +}; diff --git a/database/migrations/8_add_tags_metadata_to_logs_table.php b/database/migrations/8_add_tags_metadata_to_logs_table.php new file mode 100644 index 0000000..4d0fc05 --- /dev/null +++ b/database/migrations/8_add_tags_metadata_to_logs_table.php @@ -0,0 +1,29 @@ +json('tags')->nullable(); + $table->json('metadata')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logs', function (Blueprint $table) { + $table->dropColumn('tags'); + $table->dropColumn('metadata'); + }); + } +}; diff --git a/src/Actions/Logs/CreateFromGenericMail.php b/src/Actions/Logs/CreateFromGenericMail.php index 346faf2..fd63fd6 100644 --- a/src/Actions/Logs/CreateFromGenericMail.php +++ b/src/Actions/Logs/CreateFromGenericMail.php @@ -41,6 +41,8 @@ public function run(GenericMailDto $genericMailDto): Log hash: $genericMailDto->template->getHash(), ), 'variables' => $genericMailDto->variables, + 'tags' => $genericMailDto->tags, + 'metadata' => $genericMailDto->metadata, ]); $log->attachments()->createMany( diff --git a/src/Actions/SendMail.php b/src/Actions/SendMail.php index 8191190..50d2073 100644 --- a/src/Actions/SendMail.php +++ b/src/Actions/SendMail.php @@ -14,6 +14,7 @@ use MailCarrier\Exceptions\TemplateRenderException; use MailCarrier\Facades\MailCarrier; use MailCarrier\Jobs\SendMailJob; +use MailCarrier\Models\Log; use MailCarrier\Models\Template; class SendMail extends Action @@ -30,15 +31,21 @@ class SendMail extends Action protected bool $shouldLog = true; + protected ?Log $log = null; + /** * Send or enqueue the email. * * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ - public function run(SendMailDto $params): void + public function run(SendMailDto $params, ?Log $log = null): void { - $this->params = $params; + if ($params->recipients && !is_null($log)) { + throw new \LogicException('A Log can be passed only with a single recipient.'); + } + $this->log = $log; + $this->params = $params; $this->template = (new Templates\FindBySlug)->run($params->template); $this->recipients = $params->recipients ?: [ new RecipientDto( @@ -131,7 +138,11 @@ protected function send(RecipientDto $recipient): void error: $exception?->getMessage(), ); - $log = !$this->shouldLog ? null : (new Logs\CreateFromGenericMail)->run($genericMailDto); + if ($this->log) { + $log = $this->log; + } else { + $log = !$this->shouldLog ? null : (new Logs\CreateFromGenericMail)->run($genericMailDto); + } if ($exception) { $exception->setLog($log); diff --git a/src/Facades/MailCarrier.php b/src/Facades/MailCarrier.php index 219c98e..c9b4d66 100644 --- a/src/Facades/MailCarrier.php +++ b/src/Facades/MailCarrier.php @@ -18,6 +18,7 @@ * @method static string|null download(string $resource, ?string $disk = null) * @method static int getFileSize(string $resource, ?string $disk = null) * @method static string humanBytes(int $bytes) + * @method static array getEmailRetriesBackoff() * * @see \MailCarrier\MailCarrierManager */ diff --git a/src/Jobs/SendMailJob.php b/src/Jobs/SendMailJob.php index 4f932f0..0248fd9 100644 --- a/src/Jobs/SendMailJob.php +++ b/src/Jobs/SendMailJob.php @@ -2,6 +2,7 @@ namespace MailCarrier\Jobs; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -12,6 +13,7 @@ use MailCarrier\Dto\GenericMailDto; use MailCarrier\Enums\LogStatus; use MailCarrier\Exceptions\SendingFailedException; +use MailCarrier\Facades\MailCarrier; use MailCarrier\Mail\GenericMail; use MailCarrier\Models\Log; @@ -43,6 +45,11 @@ public function __construct( */ public function handle(): void { + // Prevent sending already sent logs, e.g. from manual retry + if ($this->log?->status === LogStatus::Sent) { + return; + } + $error = null; try { @@ -55,11 +62,13 @@ public function handle(): void (new Logs\Update)->run($this->log, [ 'status' => $error ? LogStatus::Failed : LogStatus::Sent, 'error' => $error, + 'tries' => $this->log->tries + 1, + 'last_try_at' => Carbon::now(), ]); } if ($error) { - throw (new SendingFailedException($error))->setLog($this->log); + throw (new SendingFailedException($error))->setLog($this->log->refresh()); } } @@ -83,13 +92,6 @@ protected function send(): void */ public function backoff(): array { - return [ - 5, // 5sec - 30, // 30sec - 60, // 1min - 60 * 5, // 5min - 60 * 30, // 30min - 60 * 60, // 1h - ]; + return MailCarrier::getEmailRetriesBackoff(); } } diff --git a/src/MailCarrierManager.php b/src/MailCarrierManager.php index bf78113..c73614d 100755 --- a/src/MailCarrierManager.php +++ b/src/MailCarrierManager.php @@ -168,4 +168,21 @@ public function humanBytes(int $bytes): string return round($bytes, 2) . ' ' . $units[$i]; } + + /** + * Get the retries (in seconds) and labels for a failing email. + * + * @return array + */ + public function getEmailRetriesBackoff(): array + { + return [ + 5, // 5sec + 30, // 30sec + 60, // 1min + 60 * 5, // 5min + 60 * 30, // 30min + 60 * 60, // 1h + ]; + } } diff --git a/src/MailCarrierServiceProvider.php b/src/MailCarrierServiceProvider.php index 19e2580..bc2c90e 100644 --- a/src/MailCarrierServiceProvider.php +++ b/src/MailCarrierServiceProvider.php @@ -73,6 +73,8 @@ public function configurePackage(Package $package): void '4_create_logs_table', '5_create_attachments_table', '6_transform_logs_cc_bcc_array', + '7_add_tries_to_logs_table', + '8_add_tags_metadata_to_logs_table', ]) ->runsMigrations(); } diff --git a/src/Models/Log.php b/src/Models/Log.php index 9b79927..a65a76a 100644 --- a/src/Models/Log.php +++ b/src/Models/Log.php @@ -28,8 +28,12 @@ * @property \MailCarrier\Dto\LogTemplateDto $template_frozen * @property array|null $variables * @property string|null $error + * @property int $tries * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at + * @property \Carbon\Carbon|null $last_try_at + * @property array|null $tags + * @property array|null $metadata * @property-read \MailCarrier\Models\Template|null $template * @property-read \Illuminate\Database\Eloquent\Collection $attachments */ @@ -66,6 +70,8 @@ class Log extends Model 'template_frozen', 'variables', 'error', + 'tries', + 'last_try_at', ]; /** @@ -87,6 +93,9 @@ class Log extends Model 'bcc' => CollectionOfContacts::class, 'template_frozen' => LogTemplateDto::class, 'variables' => 'array', + 'tags' => 'array', + 'metadata' => 'json', + 'last_try_at' => 'datetime', ]; /** diff --git a/src/Resources/LogResource.php b/src/Resources/LogResource.php index 2842915..93722d7 100644 --- a/src/Resources/LogResource.php +++ b/src/Resources/LogResource.php @@ -2,7 +2,11 @@ namespace MailCarrier\Resources; +use Carbon\CarbonInterface; +use Filament\Forms\Components\FileUpload; +use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Support\Colors\Color; use Filament\Support\Enums\Alignment; use Filament\Tables; use Filament\Tables\Actions\Action as TablesAction; @@ -11,8 +15,11 @@ use Illuminate\Support\Facades\View; use Illuminate\Support\HtmlString; use MailCarrier\Actions\Logs\GetTriggers; +use MailCarrier\Actions\SendMail; use MailCarrier\Dto\LogTemplateDto; +use MailCarrier\Dto\SendMailDto; use MailCarrier\Enums\LogStatus; +use MailCarrier\Facades\MailCarrier; use MailCarrier\Models\Log; use MailCarrier\Models\Template; use MailCarrier\Resources\LogResource\Pages; @@ -76,6 +83,25 @@ public static function table(Tables\Table $table): Tables\Table ->modalFooterActionsAlignment(Alignment::Center) ), + Tables\Columns\TextColumn::make('tries') + ->badge() + ->tooltip(function (Log $record) { + if ($record->status !== LogStatus::Failed || is_null($record->last_try_at)) { + return null; + } + + // We add "1" to retries count because the first try is not counted as "retry" + if ($record->tries >= count(MailCarrier::getEmailRetriesBackoff()) + 1) { + return 'No retry left.'; + } + + return 'Retrying in ' . $record->last_try_at + ->addSeconds( + MailCarrier::getEmailRetriesBackoff()[max(0, $record->tries - 1)] + ) + ->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE); + }), + Tables\Columns\TextColumn::make('created_at') ->label('Sent at') ->since() @@ -136,7 +162,7 @@ protected static function getTableActions(): array ])) ->modalSubmitAction(false) ->modalCancelActionLabel('Close') - ->modalFooterActionsAlignment(Alignment::Center), + ->modalFooterActionsAlignment(Alignment::Right), Tables\Actions\Action::make('preview') ->icon('heroicon-o-eye') @@ -146,7 +172,24 @@ protected static function getTableActions(): array ->modalWidth('7xl') ->modalSubmitAction(false) ->modalCancelActionLabel('Close') - ->modalFooterActionsAlignment(Alignment::Center), + ->modalFooterActionsAlignment(Alignment::Right), + + Tables\Actions\Action::make('manual_retry') + ->icon('heroicon-o-arrow-path') + ->color(Color::Orange) + ->form([ + FileUpload::make('attachments') + ->multiple() + ->preserveFilenames() + ->storeFiles(false), + ]) + ->modalWidth('2xl') + ->modalIcon('heroicon-o-arrow-path') + ->modalDescription('Are you sure you want to manually retry to send this email?') + ->modalSubmitActionLabel('Retry') + ->modalFooterActionsAlignment(Alignment::Right) + ->action(fn (?Log $record, array $data) => $record ? static::retryEmail($record, $data) : null) + ->visible(fn (?Log $record) => $record?->status === LogStatus::Failed), ]; } @@ -176,4 +219,40 @@ protected static function getTemplateValue(LogTemplateDto $templateDto, ?Templat ($subtitle ? '

' . $subtitle . '

' : '') ); } + + protected static function retryEmail(Log $log, array $data): void + { + try { + SendMail::resolve()->run( + new SendMailDto([ + 'template' => $log->template->slug, + 'subject' => $log->subject, + 'sender' => $log->sender, + 'recipient' => $log->recipient, + 'cc' => $log->cc->all(), + 'bcc' => $log->bcc->all(), + 'variables' => $log->variables, + 'trigger' => $log->trigger, + 'tags' => $log->tags ?: [], + 'metadata' => $log->metadata ?: [], + 'attachments' => $data['attachments'] ?? [], + ]), + $log + ); + } catch (\Throwable $e) { + Notification::make() + ->title('Error while sending email') + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->icon('heroicon-o-paper-airplane') + ->title('Email sent correctly') + ->success() + ->send(); + } }