# Bounce Rate Monitor for AmazonSESBundle

## Context

AWS SES suspends accounts when bounce rate exceeds ~5%. To protect the SES account proactively, we need Mautic to automatically stop sending ALL emails when the bounce rate (calculated from Mautic's DB over the last 24 hours) exceeds 6%. Sending must only resume when an admin explicitly runs a console command.

Bounces arrive via **SQS** (not direct SNS webhook). The standalone script `docroot/scripts/process_sqs_bounces.php` polls SQS, processes bounces, and adds DNC entries via Mautic REST API. It runs on cron every 5 minutes.

## Approach

A **lock file** (`var/bounce_rate_paused.lock`) is the bridge between the SQS script (which detects high bounce rate) and the Mautic plugin (which enforces the send block).

### Flow

1. **Detection** — After processing SQS messages, `process_sqs_bounces.php` queries the DB for bounce rate. If rate >= 6% with at least 100 emails sent in 24h, creates the lock file.
2. **Enforcement** — `BounceRateCheckSubscriber` in AmazonSESBundle checks `file_exists()` on each `EMAIL_PRE_SEND`. If lock file exists, blocks the send with `enableFatal()`.
3. **Resume** — Admin runs `bin/console mautic:ses:bounce-rate --resume` to delete the lock file.

### Bounce rate formula (1-hour rolling window)
- Sent in 1h: `SELECT COUNT(*) FROM email_stats WHERE date_sent >= NOW() - INTERVAL 1 HOUR`
- Bounced in 1h: `SELECT COUNT(*) FROM lead_donotcontact WHERE channel='email' AND reason=2 AND date_added >= NOW() - INTERVAL 1 HOUR`
- Rate = (bounced / sent) * 100
- Guard: return 0 if sent < 100 (prevents false positives from small batches)
- Using 1h window avoids contamination from historical bounces (previously used different SMTP provider) and SQS backlog processing spikes

---

## Files to Modify

### 1. `docroot/scripts/process_sqs_bounces.php`

Add bounce rate check after all messages are processed (before the summary log, around line 566). Uses same DB access pattern as `mark_dnd_from_csv.php`:

```php
// ── Bounce rate check ──────────────────────────────────
$configPath = dirname(__DIR__) . '/config/local.php';
include $configPath;
$pdo = new PDO("mysql:host={$parameters['db_host']};port={$parameters['db_port']};dbname={$parameters['db_name']}",
    $parameters['db_user'], $parameters['db_password']);
$prefix = $parameters['db_table_prefix'] ?? '';
$cutoff = date('Y-m-d H:i:s', strtotime('-1 hour'));

$sent = (int) $pdo->query("SELECT COUNT(*) FROM {$prefix}email_stats WHERE date_sent >= '{$cutoff}'")->fetchColumn();
$bounced = (int) $pdo->query("SELECT COUNT(*) FROM {$prefix}lead_donotcontact WHERE channel='email' AND reason=2 AND date_added >= '{$cutoff}'")->fetchColumn();

$bounceRate = ($sent >= 100) ? ($bounced / $sent) * 100 : 0;
$threshold = 6.0;
$lockFile = dirname(__DIR__, 2) . '/var/bounce_rate_paused.lock';

$logger->info(sprintf('Bounce rate: %.2f%% (%d bounced / %d sent in 1h, threshold: %.1f%%)',
    $bounceRate, $bounced, $sent, $threshold));

if ($bounceRate >= $threshold && !file_exists($lockFile)) {
    $lockData = json_encode([
        'paused_at' => date('Y-m-d H:i:s'),
        'bounce_rate' => round($bounceRate, 2),
        'bounced' => $bounced,
        'sent' => $sent,
    ]);
    file_put_contents($lockFile, $lockData, LOCK_EX);
    $logger->warning("SENDING PAUSED! Bounce rate {$bounceRate}% exceeds {$threshold}%. Resume with: bin/console mautic:ses:bounce-rate --resume");
}
```

Key: Uses prepared statements with `$cutoff` variable (not user input, so safe). Reads DB config from `config/local.php` same as other scripts. Lock file path: `var/bounce_rate_paused.lock` (two levels up from `docroot/scripts/`).

### 2. `docroot/plugins/AmazonSESBundle/Config/services.php`

Add `$projectDir` binding for `BounceRateMonitor`:
```php
$services->get(\MauticPlugin\AmazonSESBundle\BounceRateMonitor::class)
    ->arg('$projectDir', '%kernel.project_dir%');
```

---

## Files to Create

### 3. `docroot/plugins/AmazonSESBundle/BounceRateMonitor.php`

Core service used by both the subscriber and the console command.

Dependencies: `Doctrine\DBAL\Connection`, `Psr\Log\LoggerInterface`, `string $projectDir`

Constants: `THRESHOLD = 6.0`, `MIN_EMAILS = 100`, `WINDOW_HOURS = 1`, `LOCK_FILE_RELATIVE = '/../var/bounce_rate_paused.lock'`

Methods:
- **`isSendingPaused(): bool`** — `file_exists()` on lock file. Called on every send, microsecond-level.
- **`calculateBounceRate(): array`** — queries DB for sent + bounced in last 1h, returns `['rate' => float, 'sent' => int, 'bounced' => int]`
- **`checkAndPauseIfNeeded(): bool`** — calculates rate, creates lock file if exceeded. Returns true if newly paused. (For use by console `--check` command.)
- **`resumeSending(): bool`** — deletes lock file, returns true if file existed.
- **`getStatus(): array`** — returns `is_paused`, `bounce_rate`, `threshold`, `pause_info`

Note: `$projectDir` is the Mautic project dir (where `docroot/` lives). Lock file goes to `$projectDir/var/bounce_rate_paused.lock`. The Symfony `%kernel.project_dir%` points to the docroot, so the lock file path should be `$projectDir . '/../var/bounce_rate_paused.lock'` (same `var/` used by the SQS script).

Edge cases: division by zero guard, min sample guard, try/catch on DB queries (fail-open), lock file write failure (log + fail-open).

### 4. `docroot/plugins/AmazonSESBundle/EventSubscriber/BounceRateCheckSubscriber.php`

Listens to `EmailEvents::EMAIL_PRE_SEND`. Calls `isSendingPaused()` — if true, calls `$event->enableFatal()` + `$event->addError('Email sending paused due to high bounce rate.')`. Logs warning with resume instructions.

### 5. `docroot/plugins/AmazonSESBundle/Command/BounceRateCommand.php`

Command: `mautic:ses:bounce-rate`
- **No options** (default): display status — paused/active, current bounce rate, threshold, pause info
- **`--resume` / `-r`**: delete lock file, resume sending
- **`--check` / `-c`**: force bounce rate calculation from DB, pause if threshold exceeded (useful for cron or manual check)

---

## Verification

1. Clear cache: `/opt/cpanel/ea-php82/root/usr/bin/php bin/console cache:clear --env=prod`
2. Check status: `/opt/cpanel/ea-php82/root/usr/bin/php bin/console mautic:ses:bounce-rate` — should show ACTIVE with current bounce rate
3. Test pause: `touch /home/whizzmailer/public_html/swiss-belhotel-v2/var/bounce_rate_paused.lock` — attempt email send in Mautic, verify it's blocked
4. Test resume: `/opt/cpanel/ea-php82/root/usr/bin/php bin/console mautic:ses:bounce-rate --resume` — verify lock file removed, emails work
5. Test force-check: `/opt/cpanel/ea-php82/root/usr/bin/php bin/console mautic:ses:bounce-rate --check` — verify rate calculation output
6. Test SQS integration: run `process_sqs_bounces.php`, check log for bounce rate line at the end
