Files
timebank-cc-public/app/Models/MailingBounce.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

254 lines
8.9 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';
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);
}
/**
* 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,
];
}
}