360 lines
12 KiB
PHP
360 lines
12 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\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
|
|
{
|
|
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);
|
|
$successCount = 0;
|
|
$failureCount = 0;
|
|
$skippedCount = 0;
|
|
|
|
foreach ($this->recipients as $recipient) {
|
|
// 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})");
|
|
$skippedCount++;
|
|
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);
|
|
|
|
$successCount++;
|
|
|
|
// Add delay between emails to avoid rate limiting
|
|
if ($sendDelay > 0) {
|
|
sleep($sendDelay);
|
|
}
|
|
|
|
} catch (\Exception $e) {
|
|
$failureCount++;
|
|
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 summary before updating statistics
|
|
Log::info("MAILING: Completed bulk mail job for Mailing ID: {$this->mailing->id} - Success: {$successCount}, Failed: {$failureCount}, Skipped: {$skippedCount}");
|
|
|
|
// Update mailing statistics
|
|
$this->updateMailingStats($successCount, $failureCount);
|
|
}
|
|
|
|
/**
|
|
* Update mailing statistics
|
|
*/
|
|
protected function updateMailingStats(int $successCount, int $failureCount): void
|
|
{
|
|
$this->mailing->increment('sent_count', $successCount);
|
|
$this->mailing->increment('failed_count', $failureCount);
|
|
|
|
// Check if all jobs are complete by comparing sent + failed with total recipients
|
|
$totalProcessed = $this->mailing->sent_count + $this->mailing->failed_count;
|
|
|
|
if ($totalProcessed >= $this->mailing->recipients_count) {
|
|
$this->mailing->update([
|
|
'status' => 'sent',
|
|
'sent_at' => now()
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
|
|
// Update failure count for all recipients in this batch (only for non-quota errors)
|
|
$this->mailing->increment('failed_count', $this->recipients->count());
|
|
|
|
// If this was the last job, mark mailing as sent (even with failures)
|
|
$totalProcessed = $this->mailing->sent_count + $this->mailing->failed_count;
|
|
if ($totalProcessed >= $this->mailing->recipients_count) {
|
|
$this->mailing->update([
|
|
'status' => 'sent',
|
|
'sent_at' => now()
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(\Exception $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';
|
|
}
|
|
} |