Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View 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);
}
}
}