Files
timebank-cc-public/app/Console/Commands/BounceSimulate.php
T

414 lines
15 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\MailingBounce;
use App\Models\Mailing;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class BounceSimulate extends Command
{
protected $signature = 'bounce:simulate
{--email= : Email address to simulate bounce for (required unless --imap is used)}
{--type=hard : Bounce type: hard, soft, complaint}
{--reason= : Bounce reason string (defaults to a definitive hard-bounce pattern)}
{--mailing-id= : Optional mailing ID to link the bounce to}
{--clear : Remove all bounce records for this email first}
{--suppress : Directly suppress without going through thresholds}
{--status : Show current bounce status only, do not record anything}
{--imap : Read real bounce emails from the IMAP mailbox configured in .env}
{--dry-run : With --imap: parse and display bounces without recording them}
{--delete : With --imap: delete processed emails from the mailbox}';
protected $description = 'Simulate or process bounced emails to test the bounce/suppression pipeline.
INJECT MODE (local dev — no mailbox needed):
php artisan bounce:simulate --email=user@example.com
Record one hard bounce (default reason: "550 5.1.1 user unknown").
With suppression_threshold=1 this immediately suppresses the address.
php artisan bounce:simulate --email=user@example.com --status
Show current bounce counts and suppression state, record nothing.
php artisan bounce:simulate --email=user@example.com --clear
Delete all bounce records for this address, then show status.
php artisan bounce:simulate --email=user@example.com --clear --type=soft
Start clean, record a soft bounce (does NOT trigger suppression).
php artisan bounce:simulate --email=user@example.com --suppress
Directly suppress the address, bypassing threshold logic.
php artisan bounce:simulate --email=user@example.com --clear --type=hard --reason="mailbox unavailable" --mailing-id=3
Start clean, record a hard bounce linked to mailing #3 with a custom reason.
IMAP MODE (production — reads real bounce emails from mailbox):
php artisan bounce:simulate --imap --dry-run
Connect to the IMAP mailbox from .env, parse bounce emails, show results — record nothing.
php artisan bounce:simulate --imap
Connect, parse, and record all bounces found in the mailbox.
php artisan bounce:simulate --imap --delete
Connect, parse, record, and delete processed emails from the mailbox.
Skips gracefully if IMAP credentials are not set in .env.
Typical test flow (local):
1. bounce:simulate --email=X --status # check baseline
2. bounce:simulate --email=X # record a hard bounce
3. mailings:manage-bounces stats # inspect suppression list
4. bounce:simulate --email=X --clear # reset for next test
Typical test flow (production):
1. bounce:simulate --imap --dry-run # verify IMAP connection and parsing
2. bounce:simulate --imap --delete # process and clear mailbox';
public function handle(): int
{
if ($this->option('imap')) {
return $this->handleImap();
}
return $this->handleInject();
}
// -------------------------------------------------------------------------
// Inject mode
// -------------------------------------------------------------------------
private function handleInject(): int
{
$email = $this->option('email');
if (!$email) {
$this->error('--email is required (or use --imap to read from the mailbox).');
return self::FAILURE;
}
if ($this->option('clear')) {
$deleted = MailingBounce::where('email', $email)->delete();
$this->line("Cleared {$deleted} bounce record(s) for <comment>{$email}</comment>.");
}
$this->printStatus($email, 'Before');
if ($this->option('status')) {
return self::SUCCESS;
}
if ($this->option('suppress')) {
MailingBounce::suppressEmail($email, 'Manually suppressed via bounce:simulate');
$this->newLine();
$this->info('Directly suppressed.');
$this->printStatus($email, 'After');
return self::SUCCESS;
}
$type = $this->option('type');
$reason = $this->option('reason') ?? $this->defaultReason($type);
$mailingId = $this->option('mailing-id') ? (int) $this->option('mailing-id') : null;
if ($mailingId && !Mailing::find($mailingId)) {
$this->warn("Mailing ID {$mailingId} does not exist — recording with mailing_id = null.");
$mailingId = null;
}
$this->newLine();
$this->line("Recording <comment>{$type}</comment> bounce for <comment>{$email}</comment>");
$this->line("Reason: <comment>{$reason}</comment>");
if ($mailingId) {
$this->line("Mailing ID: <comment>{$mailingId}</comment>");
}
$isDefinitive = MailingBounce::testPatternMatching($type, $reason);
$this->line("Matches definitive hard-bounce pattern: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
MailingBounce::recordBounce($email, $type, $reason, $mailingId);
$this->newLine();
$this->info('Bounce recorded.');
$this->printStatus($email, 'After');
return self::SUCCESS;
}
// -------------------------------------------------------------------------
// IMAP mode
// -------------------------------------------------------------------------
private function handleImap(): int
{
$config = $this->getImapConfig();
if (!$config) {
$this->warn('IMAP credentials not configured in .env — skipping IMAP mode.');
$this->line('Set BOUNCE_HOST, BOUNCE_USERNAME, BOUNCE_PASSWORD (and optionally BOUNCE_PORT, BOUNCE_SSL) to enable.');
return self::SUCCESS;
}
$dryRun = $this->option('dry-run');
$delete = $this->option('delete');
$this->line('Connecting to IMAP mailbox: <comment>' . $config['host'] . '</comment> as <comment>' . $config['username'] . '</comment>');
if ($dryRun) {
$this->line('<fg=yellow>Dry run — no bounces will be recorded.</>');
}
try {
$connection = $this->imapConnect($config);
} catch (\Exception $e) {
$this->error('IMAP connection failed: ' . $e->getMessage());
return self::FAILURE;
}
$messages = $this->imapFetchBounces($connection);
if (empty($messages)) {
$this->info('No bounce emails found in mailbox.');
imap_close($connection);
return self::SUCCESS;
}
$this->line("Found <comment>" . count($messages) . "</comment> bounce email(s).");
$this->newLine();
$recorded = 0;
$skipped = 0;
foreach ($messages as $msg) {
$bounce = $this->imapParseBounce($msg);
if (!$bounce) {
$this->warn(" [#{$msg['id']}] Could not parse bounce — skipping.");
$skipped++;
continue;
}
$this->line(" [#{$msg['id']}] <comment>{$bounce['type']}</comment> bounce → <comment>{$bounce['email']}</comment>");
$this->line(" Reason: {$bounce['reason']}");
if ($bounce['mailing_id']) {
$this->line(" Mailing ID: {$bounce['mailing_id']}");
}
$isDefinitive = MailingBounce::testPatternMatching($bounce['type'], $bounce['reason']);
$this->line(" Definitive hard-bounce pattern: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
if (!$dryRun) {
$this->printStatus($bounce['email'], 'Before');
MailingBounce::recordBounce($bounce['email'], $bounce['type'], $bounce['reason'], $bounce['mailing_id']);
$this->printStatus($bounce['email'], 'After');
if ($delete) {
imap_delete($connection, $msg['id']);
}
}
$this->newLine();
$recorded++;
}
if ($delete && !$dryRun) {
imap_expunge($connection);
}
imap_close($connection);
$this->info("Done. Processed: {$recorded}, skipped: {$skipped}" . ($dryRun ? ' (dry run)' : '') . ($delete && !$dryRun ? ', deleted from mailbox.' : '.'));
return self::SUCCESS;
}
private function getImapConfig(): ?array
{
$host = env('BOUNCE_HOST');
$username = env('BOUNCE_USERNAME');
$password = env('BOUNCE_PASSWORD');
if (!$host || !$username || !$password) {
return null;
}
return [
'host' => $host,
'port' => env('BOUNCE_PORT', 993),
'username' => $username,
'password' => $password,
'ssl' => env('BOUNCE_SSL', true),
'protocol' => env('BOUNCE_PROTOCOL', 'imap'),
];
}
private function imapConnect(array $config)
{
$ssl = filter_var($config['ssl'], FILTER_VALIDATE_BOOLEAN) ? '/ssl' : '';
$mailbox = "{{$config['host']}:{$config['port']}/imap{$ssl}}INBOX";
$connection = imap_open($mailbox, $config['username'], $config['password']);
if (!$connection) {
throw new \Exception(imap_last_error());
}
return $connection;
}
private function imapFetchBounces($connection): array
{
$messages = [];
$count = imap_num_msg($connection);
for ($i = 1; $i <= $count; $i++) {
$header = imap_headerinfo($connection, $i);
$subject = $header->subject ?? '';
$rawHeaders = imap_fetchheader($connection, $i);
if ($this->imapIsBounce($header, $rawHeaders)) {
$messages[] = [
'id' => $i,
'subject' => $subject,
'body' => imap_body($connection, $i),
'header' => $header,
];
}
}
return $messages;
}
private function imapIsBounce($header, string $rawHeaders): bool
{
// Must be a genuine RFC 3464 DSN — multipart/report with delivery-status
$ct = strtolower($rawHeaders);
if (!str_contains($ct, 'multipart/report') || !str_contains($ct, 'report-type=delivery-status')) {
return false;
}
$fromMailbox = strtolower($header->from[0]->mailbox ?? '');
$fromFull = $fromMailbox . '@' . strtolower($header->from[0]->host ?? '');
foreach (['mailer-daemon', 'postmaster', 'mail-daemon'] as $sender) {
if (str_contains($fromMailbox, $sender) || str_contains($fromFull, $sender)) {
return true;
}
}
return false;
}
private function imapParseBounce(array $msg): ?array
{
$body = $msg['body'];
$subject = $msg['subject'];
$email = $this->imapExtractEmail($body);
if (!$email) {
return null;
}
return [
'email' => $email,
'type' => $this->imapDetermineBounceType($body, $subject),
'reason' => $this->imapExtractReason($body, $subject),
'mailing_id' => $this->imapExtractMailingId($body),
];
}
private function imapExtractEmail(string $body): ?string
{
$patterns = [
'/final-recipient:\s*rfc822;\s*([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/i',
'/original-recipient:\s*rfc822;\s*([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/i',
'/(?:to|for|recipient):\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/i',
'/([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $body, $matches)) {
return strtolower(trim($matches[1]));
}
}
return null;
}
private function imapDetermineBounceType(string $body, string $subject): string
{
$text = strtolower($body . ' ' . $subject);
foreach (['user unknown', 'no such user', 'invalid recipient', 'recipient address rejected',
'mailbox unavailable', 'does not exist', '5.1.1', '5.1.2', '5.1.3',
'550', '551', '553', '554'] as $pattern) {
if (str_contains($text, $pattern)) {
return 'hard';
}
}
foreach (['mailbox full', 'quota exceeded', 'temporarily rejected', 'try again later',
'temporarily unavailable', '4.2.2', '4.3.1', '4.3.2',
'421', '450', '451', '452'] as $pattern) {
if (str_contains($text, $pattern)) {
return 'soft';
}
}
return 'unknown';
}
private function imapExtractReason(string $body, string $subject): string
{
if (preg_match('/diagnostic-code:\s*(.+)/i', $body, $matches)) {
return trim($matches[1]);
}
if (preg_match('/action:\s*failed.+status:\s*(.+)/i', $body, $matches)) {
return trim($matches[1]);
}
return substr($subject, 0, 255);
}
private function imapExtractMailingId(string $body): ?int
{
if (preg_match('/x-mailing-id[:\s]*(\d+)/i', $body, $matches)) {
return (int) $matches[1];
}
if (preg_match('/mailing[_-]?id[:\s]*(\d+)/i', $body, $matches)) {
return (int) $matches[1];
}
if (preg_match('/mailing\s+#(\d+)/i', $body, $matches)) {
return (int) $matches[1];
}
return null;
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
private function printStatus(string $email, string $label): void
{
$stats = MailingBounce::getBounceStats($email);
$config = timebank_config('mailing.bounce_thresholds', []);
$this->newLine();
$this->line("=== {$label}: <comment>{$email}</comment> ===");
$this->line(" Total bounces : {$stats['total_bounces']}");
$this->line(" Recent bounces : {$stats['recent_bounces']} (window: {$stats['window_days']} days)");
$this->line(" Recent definitive hard : {$stats['recent_hard_bounces']}");
$this->line(" Suppressed : " . ($stats['is_suppressed'] ? '<fg=red>YES</>' : '<fg=green>no</>'));
$this->line(" Suppression threshold : " . ($config['suppression_threshold'] ?? '?'));
$this->line(" Verif. reset threshold : " . ($config['verification_reset_threshold'] ?? '?'));
$this->newLine();
}
private function defaultReason(string $type): string
{
return match ($type) {
'hard' => '550 5.1.1 user unknown',
'soft' => '452 mailbox full',
'complaint' => 'Spam complaint',
default => 'Unknown bounce reason',
};
}
}