v2.13.1
Bug fixes surfaced during full end-to-end testing against live Postmark, Resend, Mailgun, Amazon SES, and SendGrid integrations (5 providers × 5 scenarios each, plus sandbox mode, multi-recipient correlation, content storage, tracking declarations, multitenancy, queue-webhook processing, and the postmaster:prune command).
Eleven real bugs that were getting past the existing test suite, all because the suite's suppression-sync tests run through a FakeSync stub rather than each provider's real SDK shapes. Brings wildbit/postmark-php and resend/resend-php into require-dev so the regression tests can exercise the actual response types without making real HTTP calls.
Cross-cutting
Postmaster::sync($provider)was case-sensitive, but theprovidersJSON column onemail_addressesstores canonical-case names (Postmark,SendGrid) while config keys are lower-case identifiers (postmark,sendgrid).Postmaster::unsuppress()andEmailAddress::canApiUnsuppress()never resolved the sync class for any provider-recorded suppression. Now normalized.
Postmark
-
SuppressionSync::unsuppress()calleddeleteSuppressions($stream, [$address]), but the SDK signature is(array $entries, ?string $stream)— entries first, stream second. Plus entries must be[['EmailAddress' => '…']], not bare strings. Result: TypeError on every unsuppress call. -
Both
pull()andunsuppress()read$response->Suppressionsas a public property, but the SDK'sPostmarkSuppressionListandPostmarkSuppressionResultListdeclare it protected. The null-coalescing silently fell through to an empty list —pull()reported '0 added' on a real 27-entry list, andunsuppress()returned false on success. Switched to thegetSuppressions()accessor. -
Unsuppress treated an empty Suppressions response as failure, but Postmark returns that for the idempotent 'already cleared' case. Now treated as success unless an entry carries an explicit
Failedstatus.
Resend
-
Outbound correlation was broken because Laravel's home-grown
ResendTransportstamps the Resend email id on the email as anX-Resend-Email-IDheader but doesn't propagate it to the SentMessage.$event->sent->getMessageId()returned Symfony's auto-generated id; webhooks (carrying Resend's UUID) couldn't correlate and recorded a phantom second row at status=sent that never advanced.RecordOutboundMessagenow prefers that header. -
resend/resend-phpdeclaresclass Resendin the global namespace at a PSR-4-mapped path, so aliasinguse Resend\\Resend as ResendClienttriggered the autoloader to double-load the file and throw 'Cannot redeclare class Resend' fatal. ReferenceResend\\Client(a properly namespaced class) for the availability check instead.
Mailgun
-
Webhook signature replay-prevention window was 15 seconds. Real webhooks routinely take 20-60s when any latency is involved (tunnels, queues, slow handlers), and Mailgun's own token validity is 15 minutes. The tight window dropped legitimate webhooks. Bumped to 5 minutes.
-
The adapter pulled
providerMessageId()from the payload's top-levelidfield. That's Mailgun's event id (a short base64 token, different per event of the same message). The real Message-ID — what Symfony's Mailgun transport stored at send time — is atevent-data.message.headers.message-id. Switched. -
Symfony's Mailgun transport stores the Message-ID with angle brackets straight from the API response (
<id@domain>); Mailgun's webhook payload delivers the same value without brackets. They never matched. Strip inRecordOutboundMessage::resolveProviderMessageId()so the canonical stored form is the bare value.
Amazon SES
- Outbound correlation was broken for the same reason as Resend: Laravel's
SesTransportstamps the SES MessageId onX-SES-Message-IDafter the SDK call but leaves the SentMessage with Symfony's auto-generated id. SNS notifications couldn't correlate. Extended the header-preference list to includeX-SES-Message-IDalongsideX-Resend-Email-ID.
SendGrid
-
The adapter pulled
providerMessageId()fromsmtp-id(the email's Message-ID header with brackets). For SMTP-mode sends — the typical Laravel-with-SendGrid setup — Symfony stores the SMTP queue id from the 250 OK response on the SentMessage, NOT the Message-ID header (which Symfony only stamps on the wire). The two never matched. SendGrid'ssg_message_idfield carries the same queue id as a prefix (<queue-id>.<filter-tags>); the adapter now splits at the first dot for correlation. -
SuppressionSyncendpoint paths were prefixed with/v3/(/v3/suppression/bounces, etc), but the SDK's base URL already includes/v3. Every suppression API call hithttps://api.sendgrid.com/v3/v3/suppression/bounces/…— double v3 — and 404'd. Worse, the SDK doesn't throw on 404, soPostmaster::unsuppress()reportedcleared=['SendGrid']while SendGrid's actual list was untouched. Removed the prefix.
Ergonomic
VerifyWebhookmiddleware silently rejected on auth failure with no log entry. Now logs at info level with the provider name + originating IP, so 'webhook isn't reaching me' and 'webhook is being rejected by my signature check' are distinguishable at a tail.
Coverage added
Nineteen new regression tests across the suite (230 → 249). Every bug above is pinned. SDKs join require-dev so the tests exercise real response shapes — PostmarkSuppressionList, PostmarkSuppressionResultList, the Resend class loader, and the SendGrid endpoint constants.
Upgrading
No breaking changes. No migration. composer update stechstudio/laravel-postmaster picks up the fixes.