mail: Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3)
webklex/laravel-imap als Transport, eigener DSN-Parser nach RFC 3464 auf der rohen Nachricht: Final-Recipient, Action, Status, Diagnostic-Code und unser X-Pk-Ref aus der Original-Nachricht. Maßgeblich ist Action — Zustellbestätigungen sperren niemanden, Freitext-Rückläufer wandern nach Unparsed vor einen Menschen, Abwesenheitsnotizen nach Ignored. mail:fetch-bounces alle zehn Minuten; ohne Konto tut es nichts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8bf40ee37e
commit
f9c97ccf2f
16 changed files with 1039 additions and 2 deletions
85
tests/Feature/BounceMailProcessingTest.php
Normal file
85
tests/Feature/BounceMailProcessingTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\EmailBounce;
|
||||
use App\Services\Mail\BounceMailProcessor;
|
||||
use App\Services\Mail\DsnParser;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3):
|
||||
* eigener DSN-Parser nach RFC 3464, kein Raten aus dem Fließtext.
|
||||
*/
|
||||
function bounceFixture(string $name): string
|
||||
{
|
||||
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
|
||||
}
|
||||
|
||||
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
|
||||
/** @var TestCase $this */
|
||||
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($report)->not->toBeNull()
|
||||
->and($report->messageId)->toBe('20260915071233.ABC123@mx.kasserver.com')
|
||||
->and($report->recipients)->toHaveCount(1)
|
||||
->and($report->recipients[0]['email'])->toBe('presse@alte-firma.example')
|
||||
->and($report->recipients[0]['action'])->toBe('failed')
|
||||
->and($report->recipients[0]['status'])->toBe('5.1.1')
|
||||
->and($report->recipients[0]['diagnostic'])->toContain('User unknown')
|
||||
->and($report->reference)->toBe('pm:4711:company:815')
|
||||
->and($report->receivedAt?->toDateString())->toBe('2026-09-15');
|
||||
});
|
||||
|
||||
test('processing a hard bounce suppresses the address and keeps the reference', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(1)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue()
|
||||
->and(EmailBounce::query()->first()->reference)->toBe('pm:4711:company:815')
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
||||
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
expect(EmailBounce::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
|
||||
->and(EmailBounce::query()->first()->reference)->toBe('user:99')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a delivery confirmation blocks nobody', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delivered'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Ignored)
|
||||
->and(EmailBounce::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('free-text bounces are not guessed but parked for a human, auto-replies are ignored', function () {
|
||||
/** @var TestCase $this */
|
||||
$unparsed = app(BounceMailProcessor::class)->process(bounceFixture('freitext'));
|
||||
$ooo = app(BounceMailProcessor::class)->process(bounceFixture('abwesenheit'));
|
||||
|
||||
expect($unparsed['folder'])->toBe(BounceMailProcessor::Unparsed)
|
||||
->and($ooo['folder'])->toBe(BounceMailProcessor::Ignored)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('jemand@alter-server.example'))->toBeFalse()
|
||||
->and(ActivityEvent::query()->where('type', ActivityType::MailSuppressed->value)->where('description', 'like', '%nicht lesbar%')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the fetch command does nothing without a configured mailbox', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('imap.accounts.bounces.host', null);
|
||||
|
||||
$this->artisan('mail:fetch-bounces')->expectsOutputToContain('nicht konfiguriert')->assertSuccessful();
|
||||
});
|
||||
9
tests/Fixtures/bounces/abwesenheit.eml
Normal file
9
tests/Fixtures/bounces/abwesenheit.eml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Erika Muster <erika@example.test>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Automatische Antwort: Ihre Pressemitteilung wurde veroeffentlicht
|
||||
Auto-Submitted: auto-replied
|
||||
Message-ID: <ooo-1@example.test>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Ich bin bis zum 30.09. nicht im Buero.
|
||||
32
tests/Fixtures/bounces/delayed-4.4.1.eml
Normal file
32
tests/Fixtures/bounces/delayed-4.4.1.eml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.kasserver.com>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Delayed Mail (still being retried)
|
||||
Message-ID: <delay-1@mx.kasserver.com>
|
||||
Content-Type: multipart/report; report-type=delivery-status; boundary="DLY"
|
||||
|
||||
--DLY
|
||||
Content-Type: text/plain
|
||||
|
||||
Your message is still being retried.
|
||||
|
||||
--DLY
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.kasserver.com
|
||||
|
||||
Final-Recipient: rfc822; voll@example.test
|
||||
action: delayed
|
||||
status: 4.4.1
|
||||
Will-Retry-Until: Thu, 18 Sep 2026 10:00:00 +0200
|
||||
|
||||
--DLY
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: service@pressekonto.com
|
||||
To: voll@example.test
|
||||
X-Pk-Ref: user:99
|
||||
|
||||
Hallo.
|
||||
|
||||
--DLY--
|
||||
21
tests/Fixtures/bounces/delivered.eml
Normal file
21
tests/Fixtures/bounces/delivered.eml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.example>
|
||||
Subject: Successful Mail Delivery Report
|
||||
Message-ID: <ok-1@mx.example>
|
||||
Content-Type: multipart/report; report-type=delivery-status; boundary="OK"
|
||||
|
||||
--OK
|
||||
Content-Type: text/plain
|
||||
|
||||
Delivered.
|
||||
|
||||
--OK
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.example
|
||||
|
||||
Final-Recipient: rfc822; lebt@example.test
|
||||
Action: delivered
|
||||
Status: 2.0.0
|
||||
|
||||
--OK--
|
||||
14
tests/Fixtures/bounces/freitext.eml
Normal file
14
tests/Fixtures/bounces/freitext.eml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Date: Tue, 16 Sep 2026 10:00:00 +0200
|
||||
From: postmaster@alter-server.example
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Mail delivery failed: returning message to sender
|
||||
Message-ID: <free-1@alter-server.example>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
This message was created automatically by mail delivery software.
|
||||
|
||||
A message that you sent could not be delivered to one or more of its
|
||||
recipients. The following address(es) failed:
|
||||
|
||||
jemand@alter-server.example
|
||||
Unrouteable address
|
||||
53
tests/Fixtures/bounces/hard-5.1.1.eml
Normal file
53
tests/Fixtures/bounces/hard-5.1.1.eml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
Return-Path: <>
|
||||
Date: Tue, 15 Sep 2026 09:12:33 +0200
|
||||
From: Mail Delivery System <MAILER-DAEMON@mx.kasserver.com>
|
||||
To: bounce@pressekonto.com
|
||||
Subject: Undelivered Mail Returned to Sender
|
||||
Message-ID: <20260915071233.ABC123@mx.kasserver.com>
|
||||
Content-Type: multipart/report; report-type=delivery-status;
|
||||
boundary="ABC123.1758112353/mx.kasserver.com"
|
||||
|
||||
This is a MIME-encapsulated message.
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Notification
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
|
||||
This is the mail system at host mx.kasserver.com.
|
||||
|
||||
I'm sorry to have to inform you that your message could not
|
||||
be delivered to one or more recipients.
|
||||
|
||||
<Presse@Alte-Firma.example>: host mail.alte-firma.example[192.0.2.10] said:
|
||||
550 5.1.1 <presse@alte-firma.example>: Recipient address rejected: User
|
||||
unknown in virtual mailbox table (in reply to RCPT TO command)
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Delivery report
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; mx.kasserver.com
|
||||
X-Postfix-Queue-ID: 4XyZ12
|
||||
X-Postfix-Sender: rfc822; bounce@pressekonto.com
|
||||
Arrival-Date: Tue, 15 Sep 2026 09:12:30 +0200
|
||||
|
||||
Final-Recipient: rfc822; Presse@Alte-Firma.example
|
||||
Original-Recipient: rfc822;presse@alte-firma.example
|
||||
Action: failed
|
||||
Status: 5.1.1
|
||||
Remote-MTA: dns; mail.alte-firma.example
|
||||
Diagnostic-Code: smtp; 550 5.1.1 <presse@alte-firma.example>: Recipient
|
||||
address rejected: User unknown in virtual mailbox table
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com
|
||||
Content-Description: Undelivered Message Headers
|
||||
Content-Type: text/rfc822-headers
|
||||
|
||||
Return-Path: <bounce@pressekonto.com>
|
||||
From: pressekonto <service@pressekonto.com>
|
||||
To: presse@alte-firma.example
|
||||
Subject: Ihre Pressemitteilung wurde veroeffentlicht
|
||||
X-Pk-Ref: pm:4711:company:815
|
||||
Message-ID: <orig-1@pressekonto.com>
|
||||
|
||||
--ABC123.1758112353/mx.kasserver.com--
|
||||
Loading…
Add table
Add a link
Reference in a new issue