getMailboxConfig(); if (!$config) { $this->error('Mailbox configuration is required. Use options or configure in config/mail.php'); return 1; } $dryRun = $this->option('dry-run'); try { $connection = $this->connectToMailbox($config); $emails = $this->fetchBounceEmails($connection, $config); if (empty($emails)) { $this->info('No bounce emails found.'); return 0; } $this->info("Found " . count($emails) . " bounce emails to process:"); $processed = 0; foreach ($emails as $email) { $bounceInfo = $this->parseBounceEmail($email); if ($bounceInfo) { $this->line("Processing bounce for: {$bounceInfo['email']} ({$bounceInfo['type']})"); if (!$dryRun) { MailingBounce::recordBounce( $bounceInfo['email'], $bounceInfo['type'], $bounceInfo['reason'], $bounceInfo['mailing_id'] ?? null ); if ($this->option('delete')) { $this->deleteEmail($connection, $email['id'], $config); } } $processed++; } else { $this->warn("Could not parse bounce email ID: {$email['id']}"); } } $this->info("Processed {$processed} bounce emails" . ($dryRun ? ' (dry run)' : '')); $this->closeConnection($connection, $config); } catch (\Exception $e) { $this->error("Error processing bounce emails: " . $e->getMessage()); Log::error("Bounce processing error: " . $e->getMessage()); return 1; } return 0; } /** * Get mailbox configuration */ protected function getMailboxConfig(): ?array { // Try command options first if ($this->option('mailbox')) { return [ 'mailbox' => $this->option('mailbox'), 'host' => $this->option('host'), 'port' => $this->option('port') ?: ($this->option('protocol') === 'pop3' ? 995 : 993), 'protocol' => $this->option('protocol') ?: 'imap', 'username' => $this->option('username'), 'password' => $this->option('password'), 'ssl' => $this->option('ssl') ]; } // Try config file $config = config('mail.bounce_processing'); if ($config && isset($config['mailbox'])) { return $config; } return null; } /** * Connect to mailbox */ protected function connectToMailbox(array $config) { $protocol = strtolower($config['protocol']); if ($protocol === 'imap') { return $this->connectIMAP($config); } elseif ($protocol === 'pop3') { return $this->connectPOP3($config); } throw new \Exception("Unsupported protocol: {$protocol}"); } /** * Connect via IMAP */ protected function connectIMAP(array $config) { $host = $config['host']; $port = $config['port']; $ssl = $config['ssl'] ? '/ssl' : ''; $mailbox = "{{$host}:{$port}/imap{$ssl}}INBOX"; $connection = imap_open($mailbox, $config['username'], $config['password']); if (!$connection) { throw new \Exception("Failed to connect to IMAP server: " . imap_last_error()); } return $connection; } /** * Connect via POP3 (basic implementation) */ protected function connectPOP3(array $config) { throw new \Exception("POP3 support not implemented yet. Use IMAP instead."); } /** * Fetch bounce emails */ protected function fetchBounceEmails($connection, array $config): array { $emails = []; $numMessages = imap_num_msg($connection); for ($i = 1; $i <= $numMessages; $i++) { $header = imap_headerinfo($connection, $i); $subject = $header->subject ?? ''; // Check if this looks like a bounce email if ($this->isBounceEmail($subject, $header)) { $body = imap_body($connection, $i); $emails[] = [ 'id' => $i, 'subject' => $subject, 'body' => $body, 'header' => $header ]; } } return $emails; } /** * Check if email is a bounce */ protected function isBounceEmail(string $subject, $header): bool { $bounceIndicators = [ 'delivery status notification', 'returned mail', 'undelivered mail', 'mail delivery failed', 'bounce', 'mailer-daemon', 'postmaster', 'delivery failure', 'mail system error' ]; $subjectLower = strtolower($subject); foreach ($bounceIndicators as $indicator) { if (strpos($subjectLower, $indicator) !== false) { return true; } } // Check sender $from = $header->from[0]->mailbox ?? ''; $bounceFroms = ['mailer-daemon', 'postmaster', 'mail-daemon']; foreach ($bounceFroms as $bounceSender) { if (strpos(strtolower($from), $bounceSender) !== false) { return true; } } return false; } /** * Parse bounce email to extract information */ protected function parseBounceEmail(array $email): ?array { $body = $email['body']; $subject = $email['subject']; // Extract original recipient email $recipientEmail = $this->extractRecipientEmail($body); if (!$recipientEmail) { return null; } // Determine bounce type and reason $bounceType = $this->determineBounceType($body, $subject); $reason = $this->extractBounceReason($body, $subject); // Try to extract mailing ID if present $mailingId = $this->extractMailingIdFromBounce($body, $subject); return [ 'email' => $recipientEmail, 'type' => $bounceType, 'reason' => $reason, 'mailing_id' => $mailingId ]; } /** * Extract recipient email from bounce message */ protected function extractRecipientEmail(string $body): ?string { // Common patterns for recipient extraction $patterns = [ '/(?:to|for|recipient):\s*?/i', '/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', '/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i' // Generic email pattern ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $body, $matches)) { return $matches[1]; } } return null; } /** * Determine bounce type from message content */ protected function determineBounceType(string $body, string $subject): string { $bodyLower = strtolower($body . ' ' . $subject); // Hard bounce patterns $hardBouncePatterns = [ 'user unknown', 'no such user', 'invalid recipient', 'recipient address rejected', 'mailbox unavailable', 'does not exist', '5.1.1', '5.1.2', '5.1.3', // SMTP codes '550', '551', '553', '554' ]; foreach ($hardBouncePatterns as $pattern) { if (strpos($bodyLower, $pattern) !== false) { return 'hard'; } } // Soft bounce patterns $softBouncePatterns = [ 'mailbox full', 'quota exceeded', 'temporarily rejected', 'try again later', 'temporarily unavailable', '4.2.2', '4.3.1', '4.3.2', // SMTP codes '421', '450', '451', '452' ]; foreach ($softBouncePatterns as $pattern) { if (strpos($bodyLower, $pattern) !== false) { return 'soft'; } } return 'unknown'; } /** * Extract bounce reason */ protected function extractBounceReason(string $body, string $subject): string { // Look for diagnostic code or action field if (preg_match('/diagnostic-code:\s*(.+)/i', $body, $matches)) { return trim($matches[1]); } if (preg_match('/action:\s*(.+)/i', $body, $matches)) { return trim($matches[1]); } // Fallback to subject return substr($subject, 0, 255); } /** * Extract mailing ID from bounce if present */ protected function extractMailingIdFromBounce(string $body, string $subject): ?int { // Look for custom headers or message IDs that contain mailing info if (preg_match('/mailing[_-]?id[:\s]*(\d+)/i', $body, $matches)) { return (int) $matches[1]; } if (preg_match('/x-mailing-id[:\s]*(\d+)/i', $body, $matches)) { return (int) $matches[1]; } return null; } /** * Delete processed email */ protected function deleteEmail($connection, int $messageId, array $config): void { if ($config['protocol'] === 'imap') { imap_delete($connection, $messageId); imap_expunge($connection); } } /** * Close connection */ protected function closeConnection($connection, array $config): void { if ($config['protocol'] === 'imap') { imap_close($connection); } } }