'datetime', 'is_suppressed' => 'boolean', ]; public function mailing(): BelongsTo { return $this->belongsTo(Mailing::class); } /** * Check if an email address should be suppressed from sending */ public static function isSuppressed(string $email): bool { return self::where('email', $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' => $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 ): self { // Create the bounce record $bounce = self::create([ 'email' => $email, 'bounce_type' => $bounceType, 'bounce_reason' => $reason, 'mailing_id' => $mailingId, 'bounced_at' => now(), '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 { $config = timebank_config('mailing.bounce_thresholds', []); $countedTypes = $config['counted_bounce_types'] ?? ['hard']; $patterns = $config['definitive_hard_bounce_patterns'] ?? []; $cutoffDate = now()->subDays($windowDays); return self::where('email', $email) ->whereIn('bounce_type', $countedTypes) ->where('bounced_at', '>=', $cutoffDate) ->where(function ($query) use ($patterns) { foreach ($patterns as $pattern) { $query->orWhere('bounce_reason', 'like', "%{$pattern}%"); } }) ->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); 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 { // Update the latest bounce record to mark as suppressed 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 { $windowDays = timebank_config('bulk_mail.bounce_thresholds.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, ]; } }