380 lines
11 KiB
PHP
380 lines
11 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 ProcessBounceMailings extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*/
|
|
protected $signature = 'mailings:process-bounces
|
|
{--mailbox= : Email address to check for bounces (e.g. bounces@yourdomain.com)}
|
|
{--host= : IMAP/POP3 server hostname}
|
|
{--port= : Server port (default: 993 for IMAP SSL, 995 for POP3 SSL)}
|
|
{--protocol= : Protocol to use: imap or pop3 (default: imap)}
|
|
{--username= : Login username}
|
|
{--password= : Login password}
|
|
{--ssl : Use SSL connection}
|
|
{--delete : Delete processed bounce emails}
|
|
{--dry-run : Show what would be processed without actually processing}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*/
|
|
protected $description = 'Process bounce emails from a dedicated bounce mailbox for mailings';
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*/
|
|
public function handle()
|
|
{
|
|
// Get configuration from command options or config file
|
|
$config = $this->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*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/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);
|
|
}
|
|
}
|
|
} |