Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne Veroeffentlichungssperre: - PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist ein 422 an der Legacy-API, deterministisch ohne DNS. - email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund), Observer an Company/Contact, Job nach Commit, taeglicher Refresh, mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS. - Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste und Domainbefund, automatisch abgelehnte Meldungen mit Grund, Text zum Kopieren und CSV. Versendet nichts. - EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht auf den Schwellwert. Statuscode entscheidet vor Diagnosetext. Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer 66 der 151 Ruecklaeufer verantwortlich. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
7.7 KiB
PHP
213 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\EmailBounceKind;
|
|
use App\Enums\EmailSuppressionReason;
|
|
use App\Models\EmailBounce;
|
|
use App\Models\EmailSuppression;
|
|
use App\Models\User;
|
|
use App\Services\ActivityLog;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* Die eine Stelle, die „nicht erreichbar" bedeutet (Roadmap 1b.6).
|
|
*
|
|
* Schreibt Rückläufer-Ereignisse nach `email_bounces` und leitet daraus den
|
|
* Zustand in `email_suppressions` ab: harter Bounce → sofort gesperrt,
|
|
* weiche → ab dem dritten in sieben Tagen. Gelesen vom Versand-Gate
|
|
* (GateOutgoingMail), von der Claim-Seite und vom Admin.
|
|
*
|
|
* Adressen werden normalisiert wie überall im Projekt: Kleinschreibung,
|
|
* getrimmt. Bounce-Listen kommen aus dem Mailserver-Log und geben die
|
|
* Adresse so wieder, wie der Absender sie geschrieben hat.
|
|
*/
|
|
class EmailSuppressionService
|
|
{
|
|
public function __construct(private readonly ActivityLog $activityLog) {}
|
|
|
|
public static function normalise(?string $email): ?string
|
|
{
|
|
$email = mb_strtolower(trim((string) $email));
|
|
|
|
return $email === '' ? null : $email;
|
|
}
|
|
|
|
public function isSuppressed(?string $email): bool
|
|
{
|
|
$key = self::normalise($email);
|
|
|
|
if ($key === null) {
|
|
return false;
|
|
}
|
|
|
|
return EmailSuppression::query()->active()->where('email', $key)->exists();
|
|
}
|
|
|
|
/**
|
|
* Welche dieser Adressen sind gesperrt? Eine Abfrage für alle Empfänger
|
|
* einer Mail.
|
|
*
|
|
* @param list<string> $emails
|
|
* @return list<string> normalisiert
|
|
*/
|
|
public function suppressedAmong(array $emails): array
|
|
{
|
|
$keys = array_values(array_filter(array_map(self::normalise(...), $emails)));
|
|
|
|
if ($keys === []) {
|
|
return [];
|
|
}
|
|
|
|
return EmailSuppression::query()->active()->whereIn('email', $keys)->pluck('email')->all();
|
|
}
|
|
|
|
/**
|
|
* Ein Rückläufer-Ereignis. Idempotent über die Message-ID: Kommt dieselbe
|
|
* Mail ein zweites Mal (abgebrochener Abhol-Lauf), passiert nichts.
|
|
*
|
|
* @param array{action?: string|null, status?: string|null, diagnostic?: string|null, reference?: string|null, received_at?: Carbon|null} $details
|
|
* @return EmailBounce|null null, wenn das Ereignis schon bekannt war
|
|
*/
|
|
public function recordBounce(string $email, EmailBounceKind $kind, string $source, string $messageId, array $details = []): ?EmailBounce
|
|
{
|
|
$key = self::normalise($email);
|
|
|
|
if ($key === null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$bounce = EmailBounce::query()->create([
|
|
'message_id' => mb_substr($messageId, 0, 255),
|
|
'email' => $key,
|
|
'kind' => $kind,
|
|
'source' => $source,
|
|
'action' => isset($details['action']) ? mb_substr($details['action'], 0, 32) : null,
|
|
'status' => isset($details['status']) ? mb_substr($details['status'], 0, 16) : null,
|
|
'diagnostic' => isset($details['diagnostic']) ? mb_substr($details['diagnostic'], 0, 500) : null,
|
|
'reference' => isset($details['reference']) ? mb_substr($details['reference'], 0, 120) : null,
|
|
'received_at' => $details['received_at'] ?? now(),
|
|
]);
|
|
} catch (UniqueConstraintViolationException) {
|
|
return null;
|
|
}
|
|
|
|
if ($kind === EmailBounceKind::Relay || $kind === EmailBounceKind::Policy) {
|
|
// Kein Empfängerproblem — nur protokolliert. Relay zählt der
|
|
// Circuit Breaker separat, Richtlinien-Ablehnungen tauchen im
|
|
// Einlieferer-Bericht auf und sind Sache eines Menschen.
|
|
return $bounce;
|
|
}
|
|
|
|
$this->applyBounce($key, $kind, $source, $bounce);
|
|
|
|
return $bounce;
|
|
}
|
|
|
|
/**
|
|
* Sperre von Hand oder aus einer Bounce-Liste (Import).
|
|
*/
|
|
public function suppress(string $email, EmailSuppressionReason $reason, string $source, ?string $note = null): ?EmailSuppression
|
|
{
|
|
$key = self::normalise($email);
|
|
|
|
if ($key === null) {
|
|
return null;
|
|
}
|
|
|
|
$suppression = EmailSuppression::query()->firstOrNew(['email' => $key]);
|
|
$wasActive = $suppression->exists && $suppression->isActive();
|
|
|
|
$suppression->fill([
|
|
'reason' => $reason,
|
|
'source' => $source,
|
|
'suppressed_at' => now(),
|
|
'released_at' => null,
|
|
'released_by_user_id' => null,
|
|
'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note,
|
|
])->save();
|
|
|
|
if (! $wasActive) {
|
|
$this->activityLog->record(
|
|
ActivityType::MailSuppressed,
|
|
sprintf('%s gesperrt: %s (%s)', $key, $reason->label(), $source),
|
|
subject: $suppression,
|
|
actorLabel: $source,
|
|
);
|
|
}
|
|
|
|
return $suppression;
|
|
}
|
|
|
|
/**
|
|
* Freigabe im Admin, wenn jemand die Adresse repariert hat. Ein späterer
|
|
* Bounce setzt die Sperre neu.
|
|
*/
|
|
public function release(EmailSuppression $suppression, ?User $by = null, ?string $note = null): void
|
|
{
|
|
$suppression->forceFill([
|
|
'released_at' => now(),
|
|
'released_by_user_id' => $by?->getKey(),
|
|
'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note,
|
|
])->save();
|
|
|
|
$this->activityLog->record(
|
|
ActivityType::MailSuppressed,
|
|
sprintf('%s freigegeben', $suppression->email),
|
|
user: $by,
|
|
subject: $suppression,
|
|
);
|
|
}
|
|
|
|
private function applyBounce(string $email, EmailBounceKind $kind, string $source, EmailBounce $bounce): void
|
|
{
|
|
$suppression = EmailSuppression::query()->firstOrNew(['email' => $email], [
|
|
'reason' => EmailSuppressionReason::HardBounce,
|
|
'source' => $source,
|
|
]);
|
|
$wasActive = $suppression->exists && $suppression->isActive();
|
|
|
|
$suppression->first_bounced_at ??= $bounce->received_at;
|
|
$suppression->last_bounced_at = $bounce->received_at;
|
|
|
|
if ($kind === EmailBounceKind::Hard) {
|
|
$suppression->hard_bounce_count++;
|
|
$suppression->reason = EmailSuppressionReason::HardBounce;
|
|
$suppression->source = $source;
|
|
$suppression->suppressed_at = now();
|
|
$suppression->released_at = null;
|
|
$suppression->released_by_user_id = null;
|
|
} else {
|
|
$suppression->soft_bounce_count++;
|
|
|
|
$recentSoft = EmailBounce::query()
|
|
->where('email', $email)
|
|
->where('kind', EmailBounceKind::Soft->value)
|
|
->where('received_at', '>=', now()->subDays((int) config('bounces.soft.window_days', 7)))
|
|
->count();
|
|
|
|
if (! $wasActive && $recentSoft >= (int) config('bounces.soft.threshold', 3)) {
|
|
$suppression->reason = EmailSuppressionReason::SoftBounces;
|
|
$suppression->source = $source;
|
|
$suppression->suppressed_at = now();
|
|
$suppression->released_at = null;
|
|
$suppression->released_by_user_id = null;
|
|
}
|
|
}
|
|
|
|
$suppression->save();
|
|
|
|
if (! $wasActive && $suppression->isActive()) {
|
|
$this->activityLog->record(
|
|
ActivityType::MailSuppressed,
|
|
sprintf('%s gesperrt: %s (%s%s)', $email, $suppression->reason->label(), $source, $bounce->status ? ', '.$bounce->status : ''),
|
|
subject: $suppression,
|
|
actorLabel: $source,
|
|
meta: array_filter(['reference' => $bounce->reference, 'diagnostic' => $bounce->diagnostic]),
|
|
);
|
|
}
|
|
}
|
|
}
|