Initial commit
This commit is contained in:
316
app/Console/Commands/ManageBouncedMailings.php
Normal file
316
app/Console/Commands/ManageBouncedMailings.php
Normal file
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailingBounce;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ManageBouncedMailings extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'mailings:manage-bounces
|
||||
{action : Action to perform: list, stats, suppress, unsuppress, cleanup, check-thresholds}
|
||||
{--email= : Specific email address for suppress/unsuppress/check actions}
|
||||
{--days= : Number of days for cleanup (default: 90)}
|
||||
{--type= : Bounce type filter: hard, soft, complaint}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Manage bounced email addresses from mailings with threshold-based actions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$this->listBounces();
|
||||
break;
|
||||
case 'stats':
|
||||
$this->showStats();
|
||||
break;
|
||||
case 'suppress':
|
||||
$this->suppressEmail();
|
||||
break;
|
||||
case 'unsuppress':
|
||||
$this->unsuppressEmail();
|
||||
break;
|
||||
case 'cleanup':
|
||||
$this->cleanupOldBounces();
|
||||
break;
|
||||
case 'check-thresholds':
|
||||
$this->checkThresholds();
|
||||
break;
|
||||
default:
|
||||
$this->error("Invalid action. Use: list, stats, suppress, unsuppress, cleanup, check-thresholds");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List bounced emails
|
||||
*/
|
||||
protected function listBounces()
|
||||
{
|
||||
$query = MailingBounce::query();
|
||||
|
||||
if ($type = $this->option('type')) {
|
||||
$query->where('bounce_type', $type);
|
||||
}
|
||||
|
||||
$bounces = $query->orderBy('bounced_at', 'desc')->get();
|
||||
|
||||
if ($bounces->isEmpty()) {
|
||||
$this->info('No bounced emails found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = ['Email', 'Type', 'Reason', 'Bounced At', 'Suppressed'];
|
||||
$rows = $bounces->map(function ($bounce) {
|
||||
return [
|
||||
$bounce->email,
|
||||
$bounce->bounce_type,
|
||||
Str::limit($bounce->bounce_reason, 50),
|
||||
$bounce->bounced_at->format('Y-m-d H:i'),
|
||||
$bounce->is_suppressed ? 'Yes' : 'No',
|
||||
];
|
||||
});
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show bounce statistics
|
||||
*/
|
||||
protected function showStats()
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
|
||||
$totalBounces = MailingBounce::count();
|
||||
$suppressedEmails = MailingBounce::where('is_suppressed', true)->count();
|
||||
$hardBounces = MailingBounce::where('bounce_type', 'hard')->count();
|
||||
$softBounces = MailingBounce::where('bounce_type', 'soft')->count();
|
||||
$recentBounces = MailingBounce::where('bounced_at', '>=', now()->subDays(7))->count();
|
||||
$windowBounces = MailingBounce::where('bounced_at', '>=', now()->subDays($windowDays))->count();
|
||||
|
||||
$this->info("Mailing Bounce Statistics:");
|
||||
$this->line("Total bounces: {$totalBounces}");
|
||||
$this->line("Suppressed emails: {$suppressedEmails}");
|
||||
$this->line("Hard bounces: {$hardBounces}");
|
||||
$this->line("Soft bounces: {$softBounces}");
|
||||
$this->line("Recent bounces (7 days): {$recentBounces}");
|
||||
$this->line("Bounces in threshold window ({$windowDays} days): {$windowBounces}");
|
||||
|
||||
// Threshold configuration
|
||||
$this->line("\nThreshold Configuration:");
|
||||
$this->line(" Suppression threshold: " . ($config['suppression_threshold'] ?? 3));
|
||||
$this->line(" Verification reset threshold: " . ($config['verification_reset_threshold'] ?? 2));
|
||||
$this->line(" Counting window: {$windowDays} days");
|
||||
|
||||
// Top bouncing domains
|
||||
$topDomains = MailingBounce::select(DB::raw('SUBSTRING_INDEX(email, "@", -1) as domain, COUNT(*) as count'))
|
||||
->groupBy('domain')
|
||||
->orderBy('count', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
if ($topDomains->isNotEmpty()) {
|
||||
$this->line("\nTop bouncing domains:");
|
||||
foreach ($topDomains as $domain) {
|
||||
$this->line(" {$domain->domain}: {$domain->count} bounces");
|
||||
}
|
||||
}
|
||||
|
||||
// Emails approaching thresholds
|
||||
$this->showEmailsApproachingThresholds($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show emails that are approaching bounce thresholds
|
||||
*/
|
||||
protected function showEmailsApproachingThresholds(array $config): void
|
||||
{
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
$windowDays = $config['counting_window_days'] ?? 30;
|
||||
|
||||
// Find emails with high bounce counts but not yet suppressed
|
||||
$emailsNearThreshold = MailingBounce::select('email', DB::raw('COUNT(*) as bounce_count'))
|
||||
->where('bounce_type', 'hard')
|
||||
->where('bounced_at', '>=', now()->subDays($windowDays))
|
||||
->where('is_suppressed', false)
|
||||
->groupBy('email')
|
||||
->having('bounce_count', '>=', max(1, $verificationResetThreshold - 1))
|
||||
->orderBy('bounce_count', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
if ($emailsNearThreshold->isNotEmpty()) {
|
||||
$this->line("\nEmails Approaching Thresholds:");
|
||||
$headers = ['Email', 'Hard Bounces', 'Status'];
|
||||
$rows = $emailsNearThreshold->map(function ($item) use ($suppressionThreshold, $verificationResetThreshold) {
|
||||
$status = [];
|
||||
if ($item->bounce_count >= $suppressionThreshold) {
|
||||
$status[] = 'Will suppress';
|
||||
} elseif ($item->bounce_count >= $verificationResetThreshold) {
|
||||
$status[] = 'Will reset verification';
|
||||
}
|
||||
if (empty($status)) {
|
||||
$status[] = 'Approaching threshold';
|
||||
}
|
||||
|
||||
return [
|
||||
$item->email,
|
||||
$item->bounce_count,
|
||||
implode(', ', $status)
|
||||
];
|
||||
});
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check thresholds for a specific email or all emails
|
||||
*/
|
||||
protected function checkThresholds(): void
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if ($email) {
|
||||
$stats = MailingBounce::getBounceStats($email);
|
||||
$this->displayEmailStats($stats);
|
||||
} else {
|
||||
$this->info("Checking all emails against current thresholds...");
|
||||
|
||||
// Get all emails with bounces
|
||||
$emails = MailingBounce::distinct('email')->pluck('email');
|
||||
|
||||
$problematicEmails = [];
|
||||
foreach ($emails as $emailAddress) {
|
||||
$stats = MailingBounce::getBounceStats($emailAddress);
|
||||
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
|
||||
if ($stats['recent_hard_bounces'] >= $verificationResetThreshold) {
|
||||
$problematicEmails[] = $stats;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($problematicEmails)) {
|
||||
$this->info("No emails exceed the current thresholds.");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found " . count($problematicEmails) . " emails exceeding thresholds:");
|
||||
|
||||
foreach ($problematicEmails as $stats) {
|
||||
$this->displayEmailStats($stats);
|
||||
$this->line('---');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display bounce statistics for a specific email
|
||||
*/
|
||||
protected function displayEmailStats(array $stats): void
|
||||
{
|
||||
$config = timebank_config('mailing.bounce_thresholds', []);
|
||||
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
|
||||
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
|
||||
|
||||
$this->line("Email: {$stats['email']}");
|
||||
$this->line(" Total bounces: {$stats['total_bounces']}");
|
||||
$this->line(" Recent bounces ({$stats['window_days']} days): {$stats['recent_bounces']}");
|
||||
$this->line(" Recent hard bounces: {$stats['recent_hard_bounces']}");
|
||||
$this->line(" Currently suppressed: " . ($stats['is_suppressed'] ? 'Yes' : 'No'));
|
||||
|
||||
// Status assessment
|
||||
if ($stats['recent_hard_bounces'] >= $suppressionThreshold) {
|
||||
$this->line(" 🔴 Status: Should be suppressed ({$stats['recent_hard_bounces']} >= {$suppressionThreshold})");
|
||||
} elseif ($stats['recent_hard_bounces'] >= $verificationResetThreshold) {
|
||||
$this->line(" 🟡 Status: Should reset verification ({$stats['recent_hard_bounces']} >= {$verificationResetThreshold})");
|
||||
} else {
|
||||
$this->line(" 🟢 Status: Below thresholds");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress a specific email
|
||||
*/
|
||||
protected function suppressEmail()
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if (!$email) {
|
||||
$email = $this->ask('Enter email address to suppress');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
MailingBounce::suppressEmail($email, 'Manually suppressed via command');
|
||||
$this->info("Email {$email} has been suppressed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuppress a specific email
|
||||
*/
|
||||
protected function unsuppressEmail()
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if (!$email) {
|
||||
$email = $this->ask('Enter email address to unsuppress');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error('Invalid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = MailingBounce::where('email', $email)
|
||||
->update(['is_suppressed' => false]);
|
||||
|
||||
if ($updated > 0) {
|
||||
$this->info("Email {$email} has been unsuppressed.");
|
||||
} else {
|
||||
$this->warn("Email {$email} was not found in bounce list.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old bounces
|
||||
*/
|
||||
protected function cleanupOldBounces()
|
||||
{
|
||||
$days = $this->option('days') ?: 90;
|
||||
|
||||
if (!$this->confirm("Delete bounces older than {$days} days? This will only remove old soft bounces, keeping hard bounces and suppressions.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = MailingBounce::where('bounce_type', 'soft')
|
||||
->where('is_suppressed', false)
|
||||
->where('bounced_at', '<', now()->subDays($days))
|
||||
->delete();
|
||||
|
||||
$this->info("Deleted {$deleted} old soft bounce records.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user