Files

327 lines
12 KiB
PHP

<?php
namespace App\Models;
use App\Models\Admin;
use App\Models\Bank;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Log;
class MailingBounce extends Model
{
protected $table = 'mailing_bounces';
/**
* Bounce reasons that say the address itself does not exist. These count
* even with a 5.7.x code (Tutanota: "550 5.7.1 ... Recipient not found").
* Used when the platform config has no 'address_bounce_patterns'.
*/
public const ADDRESS_BOUNCE_PATTERNS = [
'user unknown',
'unknown user',
'no such user',
'recipient not found',
'recipient unknown',
'mailbox not found',
'does not exist',
'5.1.1',
'5.1.2',
'5.1.3',
];
/**
* Bounce reasons that reject our server rather than the address.
* Used when the platform config has no 'not_definitive_bounce_patterns'.
*/
public const NOT_DEFINITIVE_BOUNCE_PATTERNS = [
'5.7.',
'block list',
'blocklist',
'blacklist',
'spamhaus',
'spam',
'policy',
'reputation',
];
protected $fillable = [
'email',
'bounce_type',
'bounce_reason',
'mailing_id',
'bounced_at',
'is_suppressed',
];
protected $casts = [
'bounced_at' => 'datetime',
'is_suppressed' => 'boolean',
];
public function mailing(): BelongsTo
{
return $this->belongsTo(Mailing::class);
}
/**
* Normalise an email address: lowercase + trim whitespace.
* Applied consistently before every DB read or write.
*/
private static function normalise(string $email): string
{
return strtolower(trim($email));
}
/**
* Check if an email address should be suppressed from sending
*/
public static function isSuppressed(string $email): bool
{
return self::where('email', self::normalise($email))
->where('is_suppressed', true)
->exists();
}
/**
* Suppress an email address from future mailings
*/
public static function suppressEmail(string $email, string $reason = null): void
{
self::updateOrCreate(
['email' => self::normalise($email)],
[
'bounce_reason' => $reason ?? 'Email suppressed',
'bounced_at' => now(),
'is_suppressed' => true,
'bounce_type' => 'suppressed',
]
);
}
/**
* Record a bounce for an email with threshold-based actions
*/
public static function recordBounce(
string $email,
string $bounceType = 'hard',
string $reason = null,
int $mailingId = null,
?\DateTimeInterface $bouncedAt = null
): self {
$email = self::normalise($email);
// When the bounce happened, not when it was processed: a backlog of
// old bounces must not all fall inside the counting window at once.
$values = [
'bounce_type' => $bounceType,
'bounce_reason' => $reason,
'bounced_at' => $bouncedAt ?? now(),
];
// One row per address and mailing (unique index): a second report for
// the same mailing - a retry, or a soft bounce turning hard - updates
// that row. Inserting it failed and stopped every bounce run for weeks.
$bounce = $mailingId !== null
? self::updateOrCreate(['email' => $email, 'mailing_id' => $mailingId], $values)
: self::create($values + [
'email' => $email,
'mailing_id' => null,
'is_suppressed' => false, // Don't auto-suppress, use threshold-based logic
]);
// Check if this bounce should trigger threshold-based actions
if (self::isDefinitiveHardBounce($bounceType, $reason)) {
self::checkAndApplyThresholds($email, $reason);
}
return $bounce;
}
/**
* Check bounce counts and apply threshold-based actions
*/
protected static function checkAndApplyThresholds(string $email, string $reason): void
{
$config = timebank_config('mailing.bounce_thresholds', []);
$suppressionThreshold = $config['suppression_threshold'] ?? 3;
$verificationResetThreshold = $config['verification_reset_threshold'] ?? 2;
$windowDays = $config['counting_window_days'] ?? 30;
// Count recent definitive hard bounces for this email
$recentBounceCount = self::countRecentDefinitiveBounces($email, $windowDays);
Log::info("Email {$email} has {$recentBounceCount} recent hard bounces (suppression: {$suppressionThreshold}, verification reset: {$verificationResetThreshold})");
// Check verification reset threshold first (lower threshold)
if ($recentBounceCount >= $verificationResetThreshold && !self::hasVerificationBeenReset($email)) {
self::resetEmailVerificationForAddress($email, $reason);
self::markVerificationReset($email);
}
// Check suppression threshold
if ($recentBounceCount >= $suppressionThreshold) {
self::suppressEmailByThreshold($email, $reason, $recentBounceCount);
}
}
/**
* Count recent definitive hard bounces for an email address
*/
protected static function countRecentDefinitiveBounces(string $email, int $windowDays): int
{
$email = self::normalise($email);
$config = timebank_config('mailing.bounce_thresholds', []);
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
$cutoffDate = now()->subDays($windowDays);
// Matched in PHP so the same rules apply as in isDefinitiveHardBounce(),
// including the exclusion of blocks against our own server.
return self::where('email', $email)
->whereIn('bounce_type', $countedTypes)
->where('bounced_at', '>=', $cutoffDate)
->get(['bounce_type', 'bounce_reason'])
->filter(fn ($bounce) => self::isDefinitiveHardBounce($bounce->bounce_type, $bounce->bounce_reason))
->count();
}
/**
* Check if bounce qualifies as a definitive hard bounce
*/
protected static function isDefinitiveHardBounce(string $bounceType, ?string $reason): bool
{
$config = timebank_config('mailing.bounce_thresholds', []);
$countedTypes = $config['counted_bounce_types'] ?? ['hard'];
$patterns = $config['definitive_hard_bounce_patterns'] ?? [];
if (!in_array($bounceType, $countedTypes)) {
return false;
}
if (empty($reason) || empty($patterns)) {
return false;
}
$reasonLower = strtolower($reason);
$addressGone = $config['address_bounce_patterns'] ?? self::ADDRESS_BOUNCE_PATTERNS;
foreach ($addressGone as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return true;
}
}
// A block against our sending server (IP on a block list, spam or
// policy rejection, 5.7.x) says nothing about the recipient's address,
// even though it comes with a 550 code.
$notDefinitive = $config['not_definitive_bounce_patterns'] ?? self::NOT_DEFINITIVE_BOUNCE_PATTERNS;
foreach ($notDefinitive as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return false;
}
}
foreach ($patterns as $pattern) {
if (strpos($reasonLower, strtolower($pattern)) !== false) {
return true;
}
}
return false;
}
/**
* Suppress email due to threshold being reached
*/
protected static function suppressEmailByThreshold(string $email, string $reason, int $bounceCount): void
{
$email = self::normalise($email);
self::where('email', $email)
->where('is_suppressed', false)
->update(['is_suppressed' => true]);
Log::warning("Email {$email} suppressed due to {$bounceCount} hard bounces. Latest reason: {$reason}");
}
/**
* Reset email verification for all profile types using this email
*/
protected static function resetEmailVerificationForAddress(string $email, string $reason): void
{
$updatedProfiles = [];
// Reset verification for all profile types
$userUpdated = User::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($userUpdated) $updatedProfiles[] = "Users: {$userUpdated}";
$orgUpdated = Organization::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($orgUpdated) $updatedProfiles[] = "Organizations: {$orgUpdated}";
$bankUpdated = Bank::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($bankUpdated) $updatedProfiles[] = "Banks: {$bankUpdated}";
$adminUpdated = Admin::where('email', $email)->whereNotNull('email_verified_at')->update(['email_verified_at' => null]);
if ($adminUpdated) $updatedProfiles[] = "Admins: {$adminUpdated}";
if (!empty($updatedProfiles)) {
Log::warning("Reset email verification for {$email} due to hard bounce threshold. Affected: " . implode(', ', $updatedProfiles) . ". Reason: {$reason}");
}
}
/**
* Mark that verification has been reset for this email (to prevent multiple resets)
*/
protected static function markVerificationReset(string $email): void
{
// We could add a verification_reset_at column to track this, but for now
// we'll rely on the email_verified_at being null to prevent duplicate resets
}
/**
* Check if verification has already been reset for this email
*/
protected static function hasVerificationBeenReset(string $email): bool
{
// Check if any profile with this email has null email_verified_at
return User::where('email', $email)->whereNull('email_verified_at')->exists()
|| Organization::where('email', $email)->whereNull('email_verified_at')->exists()
|| Bank::where('email', $email)->whereNull('email_verified_at')->exists()
|| Admin::where('email', $email)->whereNull('email_verified_at')->exists();
}
/**
* Public method for testing pattern matching
*/
public static function testPatternMatching(string $bounceType, ?string $reason): bool
{
return self::isDefinitiveHardBounce($bounceType, $reason);
}
/**
* Get bounce statistics for an email address
*/
public static function getBounceStats(string $email): array
{
$email = self::normalise($email);
$config = timebank_config('mailing.bounce_thresholds', []);
$windowDays = $config['counting_window_days'] ?? 30;
$cutoffDate = now()->subDays($windowDays);
$totalBounces = self::where('email', $email)->count();
$recentBounces = self::where('email', $email)->where('bounced_at', '>=', $cutoffDate)->count();
$recentHardBounces = self::countRecentDefinitiveBounces($email, $windowDays);
$isSuppressed = self::isSuppressed($email);
return [
'email' => $email,
'total_bounces' => $totalBounces,
'recent_bounces' => $recentBounces,
'recent_hard_bounces' => $recentHardBounces,
'is_suppressed' => $isSuppressed,
'window_days' => $windowDays,
];
}
}