254 lines
8.9 KiB
PHP
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,
|
|
];
|
|
}
|
|
} |