Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
<?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';
}
}