Initial commit
This commit is contained in:
254
app/Models/MailingBounce.php
Normal file
254
app/Models/MailingBounce.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user