414 lines
15 KiB
PHP
414 lines
15 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 BounceSimulate extends Command
|
|
{
|
|
protected $signature = 'bounce:simulate
|
|
{--email= : Email address to simulate bounce for (required unless --imap is used)}
|
|
{--type=hard : Bounce type: hard, soft, complaint}
|
|
{--reason= : Bounce reason string (defaults to a definitive hard-bounce pattern)}
|
|
{--mailing-id= : Optional mailing ID to link the bounce to}
|
|
{--clear : Remove all bounce records for this email first}
|
|
{--suppress : Directly suppress without going through thresholds}
|
|
{--status : Show current bounce status only, do not record anything}
|
|
{--imap : Read real bounce emails from the IMAP mailbox configured in .env}
|
|
{--dry-run : With --imap: parse and display bounces without recording them}
|
|
{--delete : With --imap: delete processed emails from the mailbox}';
|
|
|
|
protected $description = 'Simulate or process bounced emails to test the bounce/suppression pipeline.
|
|
|
|
INJECT MODE (local dev — no mailbox needed):
|
|
php artisan bounce:simulate --email=user@example.com
|
|
Record one hard bounce (default reason: "550 5.1.1 user unknown").
|
|
With suppression_threshold=1 this immediately suppresses the address.
|
|
|
|
php artisan bounce:simulate --email=user@example.com --status
|
|
Show current bounce counts and suppression state, record nothing.
|
|
|
|
php artisan bounce:simulate --email=user@example.com --clear
|
|
Delete all bounce records for this address, then show status.
|
|
|
|
php artisan bounce:simulate --email=user@example.com --clear --type=soft
|
|
Start clean, record a soft bounce (does NOT trigger suppression).
|
|
|
|
php artisan bounce:simulate --email=user@example.com --suppress
|
|
Directly suppress the address, bypassing threshold logic.
|
|
|
|
php artisan bounce:simulate --email=user@example.com --clear --type=hard --reason="mailbox unavailable" --mailing-id=3
|
|
Start clean, record a hard bounce linked to mailing #3 with a custom reason.
|
|
|
|
IMAP MODE (production — reads real bounce emails from mailbox):
|
|
php artisan bounce:simulate --imap --dry-run
|
|
Connect to the IMAP mailbox from .env, parse bounce emails, show results — record nothing.
|
|
|
|
php artisan bounce:simulate --imap
|
|
Connect, parse, and record all bounces found in the mailbox.
|
|
|
|
php artisan bounce:simulate --imap --delete
|
|
Connect, parse, record, and delete processed emails from the mailbox.
|
|
|
|
Skips gracefully if IMAP credentials are not set in .env.
|
|
|
|
Typical test flow (local):
|
|
1. bounce:simulate --email=X --status # check baseline
|
|
2. bounce:simulate --email=X # record a hard bounce
|
|
3. mailings:manage-bounces stats # inspect suppression list
|
|
4. bounce:simulate --email=X --clear # reset for next test
|
|
|
|
Typical test flow (production):
|
|
1. bounce:simulate --imap --dry-run # verify IMAP connection and parsing
|
|
2. bounce:simulate --imap --delete # process and clear mailbox';
|
|
|
|
public function handle(): int
|
|
{
|
|
if ($this->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 <comment>{$email}</comment>.");
|
|
}
|
|
|
|
$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 <comment>{$type}</comment> bounce for <comment>{$email}</comment>");
|
|
$this->line("Reason: <comment>{$reason}</comment>");
|
|
if ($mailingId) {
|
|
$this->line("Mailing ID: <comment>{$mailingId}</comment>");
|
|
}
|
|
|
|
$isDefinitive = MailingBounce::testPatternMatching($type, $reason);
|
|
$this->line("Matches definitive hard-bounce pattern: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
|
|
|
|
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: <comment>' . $config['host'] . '</comment> as <comment>' . $config['username'] . '</comment>');
|
|
if ($dryRun) {
|
|
$this->line('<fg=yellow>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 <comment>" . count($messages) . "</comment> 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']}] <comment>{$bounce['type']}</comment> bounce → <comment>{$bounce['email']}</comment>");
|
|
$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: <comment>" . ($isDefinitive ? 'yes' : 'no') . "</comment>");
|
|
|
|
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*<?([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',
|
|
];
|
|
|
|
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}: <comment>{$email}</comment> ===");
|
|
$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'] ? '<fg=red>YES</>' : '<fg=green>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',
|
|
};
|
|
}
|
|
}
|