Skip to content

v2.13.1

Choose a tag to compare

@jszobody jszobody released this 28 May 02:15
· 47 commits to master since this release

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 the providers JSON column on email_addresses stores canonical-case names (Postmark, SendGrid) while config keys are lower-case identifiers (postmark, sendgrid). Postmaster::unsuppress() and EmailAddress::canApiUnsuppress() never resolved the sync class for any provider-recorded suppression. Now normalized.

Postmark

  • SuppressionSync::unsuppress() called deleteSuppressions($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() and unsuppress() read $response->Suppressions as a public property, but the SDK's PostmarkSuppressionList and PostmarkSuppressionResultList declare it protected. The null-coalescing silently fell through to an empty list — pull() reported '0 added' on a real 27-entry list, and unsuppress() returned false on success. Switched to the getSuppressions() 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 Failed status.

Resend

  • Outbound correlation was broken because Laravel's home-grown ResendTransport stamps the Resend email id on the email as an X-Resend-Email-ID header 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. RecordOutboundMessage now prefers that header.

  • resend/resend-php declares class Resend in the global namespace at a PSR-4-mapped path, so aliasing use Resend\\Resend as ResendClient triggered the autoloader to double-load the file and throw 'Cannot redeclare class Resend' fatal. Reference Resend\\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-level id field. 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 at event-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 in RecordOutboundMessage::resolveProviderMessageId() so the canonical stored form is the bare value.

Amazon SES

  • Outbound correlation was broken for the same reason as Resend: Laravel's SesTransport stamps the SES MessageId on X-SES-Message-ID after the SDK call but leaves the SentMessage with Symfony's auto-generated id. SNS notifications couldn't correlate. Extended the header-preference list to include X-SES-Message-ID alongside X-Resend-Email-ID.

SendGrid

  • The adapter pulled providerMessageId() from smtp-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's sg_message_id field carries the same queue id as a prefix (<queue-id>.<filter-tags>); the adapter now splits at the first dot for correlation.

  • SuppressionSync endpoint paths were prefixed with /v3/ (/v3/suppression/bounces, etc), but the SDK's base URL already includes /v3. Every suppression API call hit https://api.sendgrid.com/v3/v3/suppression/bounces/… — double v3 — and 404'd. Worse, the SDK doesn't throw on 404, so Postmaster::unsuppress() reported cleared=['SendGrid'] while SendGrid's actual list was untouched. Removed the prefix.

Ergonomic

  • VerifyWebhook middleware 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.