Files
timebank-cc-public/app/Console/Commands/ManageBouncedMailings.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

316 lines
11 KiB
PHP

<?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.");
}
}