'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, ]; } }