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 {$email}."); } $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 {$type} bounce for {$email}"); $this->line("Reason: {$reason}"); if ($mailingId) { $this->line("Mailing ID: {$mailingId}"); } $isDefinitive = MailingBounce::testPatternMatching($type, $reason); $this->line("Matches definitive hard-bounce pattern: " . ($isDefinitive ? 'yes' : 'no') . ""); 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: ' . $config['host'] . ' as ' . $config['username'] . ''); if ($dryRun) { $this->line('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 " . count($messages) . " 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']}] {$bounce['type']} bounce → {$bounce['email']}"); $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: " . ($isDefinitive ? 'yes' : 'no') . ""); 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*?/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}: {$email} ==="); $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'] ? 'YES' : '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', }; } }