Initial commit
This commit is contained in:
380
app/Console/Commands/ProcessBounceMailings.php
Normal file
380
app/Console/Commands/ProcessBounceMailings.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user