Files
timebank-cc-public/app/Jobs/SendBulkMailJob.php
T

419 lines
15 KiB
PHP

<?php
namespace App\Jobs;
use App\Mail\NewsletterMail;
use App\Models\MailingBounce;
use App\Models\Mailing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class SendBulkMailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $mailing;
protected $locale;
protected $recipients;
protected $contentBlocks;
public $timeout = 300; // 5 minutes timeout
/**
* Get the number of times the job may be attempted.
*/
public function tries(): int
{
return timebank_config('mailing.max_retries', 3) + 1; // +1 for initial attempt
}
/**
* Determine if the job should be retried based on time limits.
*/
public function retryUntil(): \DateTime
{
$abandonAfterHours = timebank_config('mailing.abandon_after_hours', 72);
return now()->addHours($abandonAfterHours);
}
/**
* Create a new job instance.
*/
public function __construct(Mailing $mailing, string $locale, array $contentBlocks, $recipients)
{
$this->mailing = $mailing;
$this->locale = $locale;
$this->contentBlocks = $contentBlocks;
$this->recipients = $recipients;
}
/**
* Execute the job.
*
* This job processes a batch of recipients for a specific locale.
* Multiple instances of this job may run in parallel for different batches/locales.
*/
public function handle(): void
{
// A long-lived queue worker persists App::getLocale() across jobs; reset it here
// so locale-scoped relation queries (e.g. CityLocale) never see a leftover locale
// from a previously processed batch for a different locale.
App::setLocale($this->locale);
Log::info("MAILING: Starting bulk mail batch job for Mailing ID: {$this->mailing->id}, Recipients in batch: " . count($this->recipients) . ", Locale: {$this->locale}");
$sendDelay = timebank_config('mailing.send_delay_seconds', 5);
$recipients = collect($this->recipients);
$totalInBatch = $recipients->count();
$processedInBatch = 0;
foreach ($recipients as $recipient) {
// Abort if mailing has been stopped — count remaining batch recipients as aborted
$this->mailing->refresh();
if ($this->mailing->status === 'aborted') {
$abortedCount = $totalInBatch - $processedInBatch;
Log::info("MAILING: Batch job aborted for Mailing ID: {$this->mailing->id}. Aborted (not sent): {$abortedCount}");
$this->mailing->increment('aborted_count', $abortedCount);
$this->checkCompletion();
return;
}
$processedInBatch++;
// Skip if email is already suppressed due to bounces
if (MailingBounce::isSuppressed($recipient->email)) {
Log::warning("MAILING: Skipping suppressed email: {$recipient->email} (Mailing ID: {$this->mailing->id})");
$this->mailing->increment('skipped_count', 1);
continue;
}
try {
// Send individual email synchronously - let NewsletterMail generate its own content blocks
$newsletterMail = new NewsletterMail($this->mailing, $recipient, null, $this->locale, true);
// Attempt to send email with comprehensive error handling
$this->sendEmailWithErrorHandling($recipient->email, $newsletterMail);
// Increment immediately so the report reflects live progress
$this->mailing->increment('sent_count', 1);
// Add delay between emails to avoid rate limiting
if ($sendDelay > 0) {
sleep($sendDelay);
}
} catch (\Exception $e) {
$this->mailing->increment('failed_count', 1);
Log::error("Failed to send newsletter to {$recipient->email}: " . $e->getMessage());
// Check if this is a bounce-related error
if ($this->isBounceError($e)) {
$bounceType = $this->determineBounceType($e);
MailingBounce::recordBounce(
$recipient->email,
$bounceType,
$e->getMessage(),
$this->mailing->id
);
Log::warning("Recorded bounce for {$recipient->email}: {$bounceType}");
}
// If it's an SMTP quota/limit error and configured to fail job, trigger retry
if ($this->isSMTPQuotaError($e) && timebank_config('mailing.fail_job_on_quota_error', true)) {
Log::error("SMTP quota/limit error detected for {$recipient->email}. Failing job for retry with extended delay.");
// Set longer delay for quota errors
$this->retryWithQuotaDelay();
throw $e; // This will trigger job retry
}
}
}
Log::info("MAILING: Completed bulk mail job for Mailing ID: {$this->mailing->id}");
$this->checkCompletion();
}
/**
* Check if all recipients have been processed and mark mailing as sent if so.
*/
protected function checkCompletion(): void
{
$this->mailing->refresh();
$totalProcessed = $this->mailing->sent_count + $this->mailing->failed_count
+ $this->mailing->skipped_count + $this->mailing->aborted_count;
if ($totalProcessed >= $this->mailing->recipients_count && $this->mailing->status === 'sending') {
$this->mailing->update([
'status' => 'sent',
'sent_at' => now()
]);
$this->sendSummaryEmail();
}
}
protected function sendSummaryEmail(): void
{
$user = $this->mailing->updatedByUser;
if (!$user || !$user->email) {
Log::warning("MAILING: Cannot send summary email for mailing {$this->mailing->id} — no sender user found.");
return;
}
try {
$this->mailing->refresh();
Mail::to($user->email)->send(new \App\Mail\MailingSummaryMail($this->mailing));
Log::info("MAILING: Summary email sent to {$user->email} for mailing {$this->mailing->id}.");
} catch (\Exception $e) {
Log::error("MAILING: Failed to send summary email for mailing {$this->mailing->id}: " . $e->getMessage());
}
}
protected function sendErrorEmail(\Throwable $exception): void
{
$user = $this->mailing->updatedByUser;
if (!$user || !$user->email) {
Log::warning("MAILING: Cannot send error email for mailing {$this->mailing->id} — no sender user found.");
return;
}
try {
$this->mailing->refresh();
$errorMessage = $exception->getMessage();
Mail::to($user->email)->send(new \App\Mail\MailingErrorMail(
$this->mailing,
$errorMessage,
$this->locale,
count($this->recipients)
));
Log::info("MAILING: Error report email sent to {$user->email} for mailing {$this->mailing->id}.");
} catch (\Exception $e) {
Log::error("MAILING: Failed to send error report email for mailing {$this->mailing->id}: " . $e->getMessage());
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
// Check if this is a quota error and provide specific guidance
if ($this->isSMTPQuotaError($exception)) {
Log::error("Bulk mail job failed due to SMTP quota/limit for mailing {$this->mailing->id}, locale {$this->locale}. This job will be retried automatically when the quota resets.");
// Don't increment failed count for quota errors as they will be retried
return;
}
Log::error("Bulk mail job failed for mailing {$this->mailing->id}, locale {$this->locale}: " . $exception->getMessage());
// Send error report to the user who triggered the mailing
$this->sendErrorEmail($exception);
// Count all unprocessed recipients in this batch as failed
$this->mailing->refresh();
$remaining = $this->mailing->recipients_count
- $this->mailing->sent_count - $this->mailing->failed_count
- $this->mailing->skipped_count - $this->mailing->aborted_count;
$unprocessed = min(count($this->recipients), max(0, $remaining));
if ($unprocessed > 0) {
$this->mailing->increment('failed_count', $unprocessed);
}
$this->checkCompletion();
}
/**
* Send email with comprehensive error handling
*/
protected function sendEmailWithErrorHandling($email, $newsletterMail)
{
try {
// Use a more specific approach to detect SMTP errors
Mail::to($email)->send($newsletterMail);
// Additional verification: Check if we can still send (for services that don't throw immediate exceptions)
// This is a lightweight check that can help detect quota issues
$this->verifyEmailServiceHealth();
} catch (\Symfony\Component\Mailer\Exception\TransportException $e) {
// Specific handling for Symfony mailer transport exceptions
Log::error("Symfony Transport Exception for {$email}: " . $e->getMessage());
throw $e;
} catch (\Swift_TransportException $e) {
// Legacy SwiftMailer transport exceptions (if still in use)
Log::error("Swift Transport Exception for {$email}: " . $e->getMessage());
throw $e;
}
}
/**
* Check if the error is related to SMTP quota/limits
*/
protected function isSMTPQuotaError(\Throwable $e): bool
{
$message = strtolower($e->getMessage());
// Common SMTP quota/limit error patterns
$quotaPatterns = [
'email limit',
'quota exceeded',
'rate limit',
'too many emails',
'limit reached',
'billing',
'upgrade your plan',
'sending limit',
'daily limit',
'monthly limit'
];
foreach ($quotaPatterns as $pattern) {
if (strpos($message, $pattern) !== false) {
return true;
}
}
return false;
}
/**
* Lightweight check to verify email service is still operational
*/
protected function verifyEmailServiceHealth()
{
// This method can be expanded to include specific health checks
// For now, it serves as a placeholder for future enhancements
// Future implementations could include:
// - Test connection to SMTP server
// - Check service status endpoints
// - Validate remaining quota if API available
}
/**
* Set retry delay specifically for quota errors
*/
protected function retryWithQuotaDelay()
{
// Store quota error information for custom backoff
cache()->put("bulk_mail_quota_error_{$this->mailing->id}", now(), now()->addHours(24));
}
/**
* Override backoff to handle quota errors differently
*/
public function backoff(): array
{
// Check if this is a quota error retry
if (cache()->has("bulk_mail_quota_error_{$this->mailing->id}")) {
$quotaDelayHours = timebank_config('mailing.quota_error_retry_delay_hours', 6);
return [$quotaDelayHours * 3600]; // Convert hours to seconds
}
// Use original backoff logic for non-quota errors
$baseDelayMinutes = timebank_config('mailing.retry_delay_minutes', 15);
$multiplier = timebank_config('mailing.retry_multiplier', 2);
$maxDelayHours = timebank_config('mailing.max_retry_delay_hours', 24);
$maxRetries = timebank_config('mailing.max_retries', 3);
$backoffSchedule = [];
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
$delayMinutes = $baseDelayMinutes * pow($multiplier, $attempt - 1);
$delayHours = $delayMinutes / 60;
// Cap the delay at max_retry_delay_hours
if ($delayHours > $maxDelayHours) {
$delayMinutes = $maxDelayHours * 60;
}
$backoffSchedule[] = $delayMinutes * 60; // Convert to seconds
}
return $backoffSchedule;
}
/**
* Check if the error indicates an email bounce
*/
protected function isBounceError(\Exception $e): bool
{
$message = strtolower($e->getMessage());
// Common bounce error patterns
$bouncePatterns = [
'mailbox unavailable',
'user unknown',
'no such user',
'invalid recipient',
'address rejected',
'mailbox full',
'quota exceeded',
'550 ',
'551 ',
'552 ',
'553 ',
'bounce',
'undeliverable',
'does not exist',
'mailbox not found',
];
foreach ($bouncePatterns as $pattern) {
if (strpos($message, $pattern) !== false) {
return true;
}
}
return false;
}
/**
* Determine bounce type based on error message
*/
protected function determineBounceType(\Exception $e): string
{
$message = strtolower($e->getMessage());
// Hard bounce patterns (permanent failures)
$hardBouncePatterns = [
'user unknown',
'no such user',
'invalid recipient',
'address rejected',
'does not exist',
'mailbox not found',
'550 ',
'551 ',
];
foreach ($hardBouncePatterns as $pattern) {
if (strpos($message, $pattern) !== false) {
return 'hard';
}
}
// Soft bounce patterns (temporary failures)
$softBouncePatterns = [
'mailbox full',
'quota exceeded',
'552 ',
'temporarily rejected',
];
foreach ($softBouncePatterns as $pattern) {
if (strpos($message, $pattern) !== false) {
return 'soft';
}
}
return 'unknown';
}
}