Skip to content

feat(laravel): send Mailables through PostHog with the posthog mailer - #319

Draft
Silthus wants to merge 11 commits into
feat/symfony-mailer-transportfrom
feat/laravel-posthog-mail-driver
Draft

Silthus wants to merge 11 commits into
feat/symfony-mailer-transportfrom
feat/laravel-posthog-mail-driver

Conversation

@Silthus

@Silthus Silthus commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

A Laravel app that already sends mail through Mailables and notifications can't pick PostHog as its mail provider today: it would have to rewrite each mail as a direct emails()->send() call and lose Laravel's queueing, previews and test helpers.

This adds a posthog mail transport to posthog-laravel. An app adds a mailer to config/mail.php and sets MAIL_MAILER=posthog, and every Mailable and mail notification sends through PostHog:

'mailers' => [
    'posthog' => ['transport' => 'posthog', 'default_template_key' => 'app-mail'], // the key is optional
],
  • Mail::extend('posthog', ...) registers MailDriver, a thin transport that sends each mail through the core PostHogTransport from feat(mailer): send Symfony Mailer email through PostHog with posthog+api:// #305. It registers only once the app builds a mailer, so an app that sends no mail never builds Laravel's mail manager.
  • Each mail sends under its Mailable or notification class in kebab case as its template key, so App\Mail\OrderShipped groups as order-shipped. That holds through failover, round-robin and Mail::build() mailers too. An X-PostHog-Template-Key header on the mail wins. Mail with no class, such as Mail::raw(), uses the mailer's default_template_key, or laravel. Derived keys are cut to the send API's 256 characters.
  • The mail is never changed: a MessageSending listener remembers each mail's key, and the transport passes it to the core as its default key. So the next mailer of a failover, or any other transport, never sends a PostHog header. A failover or round-robin mailer hands the transport a clone, so the driver finds the key through Laravel's envelope, which holds the mail Laravel named.
  • A default_template_key the send API rejects fails when the mailer is built, checked with the core's own rule.
  • The core transport is built per send from the current scoped client, so each queued job, request or Octane worker sends with its own PostHog configuration.
  • The integration marker is laravel/<posthog-laravel version>. Templates made in PostHog are off, as the adapter contract says for in-package adapters. Refusals and send errors are the core transport's (PostHog cannot send this email: <code>, PostHogTransportException).

Stacked on #305 (merge that first). Spec: the in-package adapters section of the email-frameworks spec. Ticket: https://github.com/PostHog/michaelr-context/issues/50

Impact

Pays into Grow Workflows (Messaging): an API send is a billed Workflows email action. After release, watch weekly organizations whose transactional email api send events come from a framework integration, broken down by client; this PR's sends count as client = laravel. Success: Laravel sends appear and grow after release day. The client property and its insight come from the instrumentation stack.

Note

🔵 Merge risk: low · one-way door once posthog-laravel releases: the posthog transport name, its default_template_key option and the class-name template keys become a contract apps configure against

  • Apps that configure a mailer with 'transport' => 'posthog' (none today: the transport is new) send their mail through PostHog's send API, US or EU by POSTHOG_HOST. Covered by MailDriverTest through a booted app and a loopback send API: Mailable, notification, raw mail, failover clones, a mail sent while another sends, Mail::build(), header options, per-job configuration, a rejected default key, refusal, send error and (on Symfony Mailer 8.2) a refused template.
  • Every other app that sends mail now runs one MessageSending listener that records the mail's key in a weak map and never changes the mail. Covered by the tests that send through another mailer and fail over from PostHog to the next mailer with no PostHog header, and by the unchanged Laravel suite on Laravel 12 and 13.
  • Apps that send no mail never build the mail manager: testAnAppThatSendsNoMailBuildsNoMailer.
  • Uncovered row: the failover key lookup reads the mail from Symfony's @internal DelayedEnvelope, a private property. If a later Symfony Mailer renames it, mail through a failover or round-robin mailer sends under the default key (laravel or default_template_key) with no error; the send itself goes out. The failover tests in the Laravel 12 and 13 CI matrix catch it on their next run.
  • Switch-off: none needed: nothing sends unless an app configures the posthog transport. Fix forward.
  • Tier: critical (f: the merge to main releases posthog-laravel with this transport)

💚 How did you test it?

  • packages/laravel/tests/MailDriverTest.php boots a Laravel app with MAIL_MAILER=posthog and sends through the real SDK to the core's loopback test server (test/LocalHttpServer.php). It asserts the request PostHog receives: path, Authorization, X-PostHog-Integration: laravel/<version>, Idempotency-Key, and the Laravel-owned body fields (template_key, to, from, subject, html).
  • Before the change, the new tests fail with Unsupported mail transport [posthog]. Each review fix started from a failing test. Failover mail lost its key; a job sent with the previous job's key; a long class name was refused; two mails that read alike sent under the first one's key; a classless mail took an earlier mail's key; a mail sent from a listener while another sent took the outer mail's key; a rejected default key went unnoticed for mail with a class.
  • The Laravel suite passes on Laravel 13 and Laravel 12 through scripts/test-laravel.sh. The template refusal test needs Symfony Mailer 8.2 (only a beta today) and skips below it; it passes on 8.2.0-BETA1. The core suite, composer api:check and composer api:check:laravel pass; the new class is @internal.
  • Every behaviour was mutation-checked, and each mutation fails a test. The mutations: building the mailer at boot, a header in the listener, a cached client, dropping the envelope lookup, the default-key check (each of its rules), the key order, the length cut, the notification name, explicit-header precedence, the kebab case, the default key, the marker, and turning templates on. One mutation survives as equivalent: looking at the mail itself first, which is the envelope's mail whenever it is recorded.
  • Not run here: a live send to PostHog. The email-frameworks launch train sends one real mail from this branch's head and checks the mailbox and delivery log.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm change to generate a change intent file (posthog-laravel minor)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built by Claude Code (Opus 5.5) as one stack of the email-frameworks work, following the shared adapter contract. Reviews ran on GPT-6.1 Sol, with a fresh-eyes pass on Claude Opus 5.5.

Decisions
Question Answer Who Where
Mode and ready step Build as a draft, mark ready at the Ready gate, merge only on the maintainer's word 👤 map decisions
Live sends The map's launch train sends once from each stack's head; stacks run none of their own 👤 map decisions
How the class name reaches the transport A MessageSending listener records each mail's key in a weak map; the mail is never changed 🤖 decisions
Failover and round-robin clones Look the key up by the mail Laravel's envelope holds; replaced matching the newest equal mail 🤖 decisions
Where the derived key goes The core transport's default key, so an explicit header still wins 🤖 decisions
Client per send Built per send from the current scoped client 🤖 decisions
Derived key length Cut to 256, never refused 🤖 decisions
A default_template_key the send API rejects Refused when the mailer is built 🤖 decisions
Template key of a mail notification The notification class in kebab case, like a Mailable 🤖 decisions
Template refusal test below Symfony Mailer 8.2 Skips; proven on 8.2.0-BETA1 🤖 decisions
posthog-php minimum Stays the exact pin the release rewrites to the core released in the same plan 🤖 decisions

Involvement: no grilling or prototype rounds; four review rounds of six lenses and a final read; local proof captured.

🤖 Generated with Claude Code

…with the posthog mailer

A Laravel app adds `'posthog' => ['transport' => 'posthog']` to config/mail.php and sets
MAIL_MAILER=posthog. The mailer registers the core PostHogTransport with templates off and the
marker laravel/<posthog-laravel version>, only once the app builds a mailer. Each Mailable or
mail notification sends under its class name in kebab case as its template key, unless the mail
sets X-PostHog-Template-Key; mail without a class uses the mailer's default_template_key.

CI installs the Laravel package against the current SDK, as main already does.

Ticket: PostHog/michaelr-context#50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-13T08:03:21.813591+00:00
Duration: 116019ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 27ms
Format Validation.Event Has Uuid ✅ 522ms
Format Validation.Event Has Lib Properties ✅ 523ms
Format Validation.Distinct Id Is String ✅ 523ms
Format Validation.Token Is Present ✅ 524ms
Format Validation.Custom Properties Preserved ✅ 523ms
Format Validation.Event Has Timestamp ✅ 523ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 523ms
Retry Behavior.Retries On 503 ✅ 5831ms
Retry Behavior.Does Not Retry On 400 ✅ 2524ms
Retry Behavior.Does Not Retry On 401 ✅ 2527ms
Retry Behavior.Respects Retry After Header ❌ 6331ms
Retry Behavior.Implements Backoff ✅ 16245ms
Retry Behavior.Retries On 500 ✅ 5630ms
Retry Behavior.Retries On 502 ✅ 5631ms
Retry Behavior.Retries On 504 ✅ 5632ms
Retry Behavior.Max Retries Respected ✅ 16246ms
Deduplication.Generates Unique Uuids ✅ 528ms
Deduplication.Preserves Uuid On Retry ✅ 5629ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5635ms
Deduplication.No Duplicate Events In Batch ✅ 528ms
Deduplication.Different Events Have Different Uuids ✅ 525ms
Compression.Sends Gzip When Enabled ✅ 523ms
Batch Format.Uses Proper Batch Structure ✅ 523ms
Batch Format.Flush With No Events Sends Nothing ✅ 520ms
Batch Format.Multiple Events Batched Together ✅ 510ms
Error Handling.Does Not Retry On 403 ✅ 2524ms
Error Handling.Does Not Retry On 413 ✅ 2527ms
Error Handling.Retries On 408 ✅ 5629ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 802ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 524ms
Request Payload.Flags Request Uses V2 Query Param ✅ 522ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 521ms
Request Payload.Flags Request Omits Authorization Header ✅ 521ms
Request Payload.Token In Flags Body Matches Init ✅ 521ms
Request Payload.Groups Round Trip ✅ 521ms
Request Payload.Groups Default To Empty Object ✅ 521ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 521ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 521ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 522ms
Request Lifecycle.No Flags Request On Init Alone ✅ 517ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 527ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 525ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 523ms
Retry Behavior.Retries Flags On 502 ✅ 624ms
Retry Behavior.Retries Flags On 504 ✅ 624ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 523ms

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-13T08:03:00.393553+00:00
Duration: 116366ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 46ms
Format Validation.Event Has Uuid ✅ 518ms
Format Validation.Event Has Lib Properties ✅ 520ms
Format Validation.Distinct Id Is String ✅ 521ms
Format Validation.Token Is Present ✅ 520ms
Format Validation.Custom Properties Preserved ✅ 521ms
Format Validation.Event Has Timestamp ✅ 520ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 522ms
Retry Behavior.Retries On 503 ✅ 5829ms
Retry Behavior.Does Not Retry On 400 ✅ 2522ms
Retry Behavior.Does Not Retry On 401 ✅ 2521ms
Retry Behavior.Respects Retry After Header ❌ 6331ms
Retry Behavior.Implements Backoff ✅ 16241ms
Retry Behavior.Retries On 500 ✅ 5630ms
Retry Behavior.Retries On 502 ✅ 5628ms
Retry Behavior.Retries On 504 ✅ 5627ms
Retry Behavior.Max Retries Respected ✅ 17032ms
Deduplication.Generates Unique Uuids ✅ 238ms
Deduplication.Preserves Uuid On Retry ✅ 5627ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10835ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5630ms
Deduplication.No Duplicate Events In Batch ✅ 527ms
Deduplication.Different Events Have Different Uuids ✅ 522ms
Compression.Sends Gzip When Enabled ✅ 522ms
Batch Format.Uses Proper Batch Structure ✅ 521ms
Batch Format.Flush With No Events Sends Nothing ✅ 517ms
Batch Format.Multiple Events Batched Together ✅ 510ms
Error Handling.Does Not Retry On 403 ✅ 2523ms
Error Handling.Does Not Retry On 413 ✅ 2522ms
Error Handling.Retries On 408 ✅ 5630ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 804ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 520ms
Request Payload.Flags Request Uses V2 Query Param ✅ 518ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 519ms
Request Payload.Flags Request Omits Authorization Header ✅ 518ms
Request Payload.Token In Flags Body Matches Init ✅ 520ms
Request Payload.Groups Round Trip ✅ 518ms
Request Payload.Groups Default To Empty Object ✅ 518ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 518ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 518ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 519ms
Request Lifecycle.No Flags Request On Init Alone ✅ 515ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 523ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 519ms
Retry Behavior.Retries Flags On 502 ✅ 623ms
Retry Behavior.Retries Flags On 504 ✅ 620ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 520ms

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-13T08:03:00.064954+00:00
Duration: 116124ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 30ms
Format Validation.Event Has Uuid ✅ 524ms
Format Validation.Event Has Lib Properties ✅ 527ms
Format Validation.Distinct Id Is String ✅ 526ms
Format Validation.Token Is Present ✅ 526ms
Format Validation.Custom Properties Preserved ✅ 526ms
Format Validation.Event Has Timestamp ✅ 528ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 526ms
Retry Behavior.Retries On 503 ✅ 5847ms
Retry Behavior.Does Not Retry On 400 ✅ 2530ms
Retry Behavior.Does Not Retry On 401 ✅ 2529ms
Retry Behavior.Respects Retry After Header ❌ 6339ms
Retry Behavior.Implements Backoff ✅ 16269ms
Retry Behavior.Retries On 500 ✅ 5642ms
Retry Behavior.Retries On 502 ✅ 5639ms
Retry Behavior.Retries On 504 ✅ 5640ms
Retry Behavior.Max Retries Respected ✅ 16270ms
Deduplication.Generates Unique Uuids ✅ 530ms
Deduplication.Preserves Uuid On Retry ✅ 5638ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10856ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5644ms
Deduplication.No Duplicate Events In Batch ✅ 529ms
Deduplication.Different Events Have Different Uuids ✅ 527ms
Compression.Sends Gzip When Enabled ✅ 528ms
Batch Format.Uses Proper Batch Structure ✅ 526ms
Batch Format.Flush With No Events Sends Nothing ✅ 516ms
Batch Format.Multiple Events Batched Together ✅ 516ms
Error Handling.Does Not Retry On 403 ✅ 2528ms
Error Handling.Does Not Retry On 413 ✅ 2528ms
Error Handling.Retries On 408 ✅ 5641ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 809ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 519ms
Request Payload.Flags Request Uses V2 Query Param ✅ 519ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 519ms
Request Payload.Flags Request Omits Authorization Header ✅ 519ms
Request Payload.Token In Flags Body Matches Init ✅ 519ms
Request Payload.Groups Round Trip ✅ 520ms
Request Payload.Groups Default To Empty Object ✅ 519ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 519ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 519ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 519ms
Request Lifecycle.No Flags Request On Init Alone ✅ 516ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 513ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 522ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 520ms
Retry Behavior.Retries Flags On 502 ✅ 621ms
Retry Behavior.Retries Flags On 504 ✅ 622ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 527ms

Silthus and others added 4 commits November 13, 2026 05:56
…nto feat/laravel-posthog-mail-driver

# Conflicts:
#	packages/laravel/README.md
…and send with each job's client

The posthog transport now remembers each mail's class name when Laravel sends it and reads it at the
transport, so Mailables keep their class-name template key inside failover, round-robin and on-demand
mailers, while every other transport sends the mail untouched. It builds the core transport per send,
so each queued job, request or Octane worker sends with its own scoped client and configuration.
Derived keys are cut to the send API's 256 characters.

The tests use the core SDK's loopback server, and check that an app that sends no mail builds no
mailer, in place of an autoload spy.

Ticket: PostHog/michaelr-context#50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mailer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…template key

A failover or round-robin mailer hands the transport a clone, which the
driver matched to the first equal mail it had named. When the app still
held an earlier mail with the same content, the new mail sent under the
earlier class's key, and a mail with no class took that key over the
mailer's default. The driver now names every mail, classless ones too,
and takes the newest equal one: Laravel names a mail just before its
transport sends it.

The derived key now goes to the core transport as its default template
key instead of a header on the mail; the core keeps an explicit
X-PostHog-Template-Key first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building and reviewing this PR:

  • 🤖 How the class name reaches the transport A MessageSending listener records each mail's class-name key in a WeakMap keyed by the mail; the mail itself is never changed, so no other transport sends a PostHog header.
  • 🤖 Failover and round-robin clones Symfony hands each transport a clone of the mail but the same envelope, and Laravel's DelayedEnvelope holds the mail Laravel named, so the driver looks the key up by that mail. It reads a private Symfony property, falls back to the mail itself when it is missing, and the failover tests in the Laravel 12 and 13 matrix catch a rename. This replaces matching the newest equal mail, which gave a mail sent from a MessageSending listener's nested send the wrong key.
  • 🤖 Where the derived key goes To the core transport as its default template key, so the core keeps an explicit X-PostHog-Template-Key first and validates it.
  • 🤖 Client per send The core transport is built on each send from the current scoped client, so each queued job, request or Octane worker sends with its own configuration.
  • 🤖 Derived key length Cut to 256, the send API's limit, and never refused.
  • 🤖 A default_template_key the send API rejects Refused when the mailer is built, so it fails every mail, not only mail without a class.
  • 🤖 Template key of a mail notification The notification class in kebab case, like a Mailable.
  • 🤖 Template refusal test below Symfony Mailer 8.2 Skips, since no template carrier exists there; it runs and passes on 8.2.0-BETA1.
  • 🤖 posthog-php minimum for posthog-laravel Stays the exact pin the release rewrites to the core version released in the same plan, which carries feat(mailer): send Symfony Mailer email through PostHog with posthog+api:// #305's transport.

Silthus and others added 4 commits November 13, 2026 07:19
…se a bad default key up front

A failover or round-robin mailer hands the PostHog transport a clone of
the mail, which the driver matched to the newest equal mail it had
named. A listener that sent an equal mail while another was sending
gave the outer mail the inner one's key. Laravel's envelope, which
Symfony hands every transport of the mailer, holds the mail Laravel
named, so the driver now looks the key up by that mail and matches
nothing by content.

Since mail with a class sends under its own key, a default_template_key
the send API rejects failed only mail without one. The mailer now
refuses that key when it is built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mailer checked default_template_key against its own copy of the
send API's template key rule. It now uses the core mapper's pattern,
which moves with the exact core version posthog-laravel pins, and the
README says which characters the key takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed a31e858: 1 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment on lines +78 to +79
return $this->transportForThisSend(self::$templateKeys[$mail] ?? $this->defaultTemplateKey)
->send($message, $envelope);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Remove reserved PostHog headers before fallback transports receive the mail

When a Mailable sets X-PostHog-Distinct-Id or X-PostHog-Idempotency-Key and PostHog fails over to SMTP, recipients receive those headers, exposing internal identifiers. send() delegates to a core transport that clones the message, leaving the fallback's original headers intact. Extract reserved options out of band and strip them from the shared message.

Silthus and others added 2 commits November 13, 2026 07:56
…ext mailer

A Mailable sets its PostHog options, such as X-PostHog-Distinct-Id and
X-PostHog-Idempotency-Key, as headers. When the PostHog send failed, a
failover mailer handed the next transport, such as SMTP, a clone of the
mail with those headers still on it, so the recipient got them. Once
PostHog has tried a mail, the driver now removes its X-PostHog-* headers
from the mail Laravel handed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 856c2eb: 1 Should fix.

Comment on lines +83 to +84
} finally {
self::removePostHogHeaders($mail);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Preserve idempotency metadata when retrying the same email

When a caller retries the same email object after a PostHogTransportException, this finally block has removed its X-PostHog-Idempotency-Key. The next send() generates a fresh key, so a timeout after PostHog accepted the first request results in duplicate delivery. Preserve the extracted options out of band across retries while keeping fallback headers clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant