presseportale/app/Services/Mail/EmailSuppressionService.php
Kevin Adametz f581e1eb3a mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
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>
2026-09-16 09:22:39 +00:00

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]),
);
}
}
}