Skip to content

feat(emails): name the sending integration with an integration option - #304

Open
Silthus wants to merge 59 commits into
mainfrom
feat/emails-integration-option
Open

Silthus wants to merge 59 commits into
mainfrom
feat/emails-integration-option

Conversation

@Silthus

@Silthus Silthus commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

A mailer integration built on posthog-php, such as the Symfony Mailer transport that stacks on this PR or the Laravel mail driver, can't tell PostHog that it sent an email. Every send looks like a plain posthog-php call, so PostHog can't count framework sends per integration.

emails()->send() now takes one more option, 'integration' => '<name>/<version>', such as laravel/1.4.0. The client checks it against ^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$ and sends it as the X-PostHog-Integration header of that send, on every retry. A bad value fails locally with invalid_integration and sends nothing, not even a file upload. The User-Agent and the analytics client don't change. Apps leave the option out; integrations set it.

Stacked on #292 (merge that first). #292 now carries main itself, and this branch merges #292's head forward, so this PR's diff against #292 is only the integration option.

Seams for reviewers. The same option, with the same pattern and header, lands in posthog-node and posthog-ruby; the server reads the header's name into the send event's client. The Symfony Mailer transport stacked on this PR passes posthog-php-mailer/<version>, and the Laravel driver will pass laravel/<version> through that transport. A break here is a value one of those accepts and this one refuses, or the reverse. The sdk-specs emails spec doesn't cover integration, so nothing here diverges from it. Ticket: https://github.com/PostHog/michaelr-context/issues/42

Impact

Lets PostHog count transactional email sends per framework integration. Watch, from release day: weekly organizations whose transactional email API sends come from a framework integration (the send event's client), broken down by integration. Success: that count rises after the adapter releases, for teams on those frameworks. This PR only adds the marker; the adapters stacked on it set it.

Note

🔵 Merge risk: low · two-way door until the SDK release; the stack's merge to main starts that release and makes integration public API

  • Callers that pass integration, in every region: on feat: send cc, bcc and attachments with emails()->send() #292 the send is refused locally as invalid_form; here it carries X-PostHog-Integration on every attempt. Covered by the tests on both forms and on retries, and by the cross-version request diff.
  • Callers that pass a malformed integration: refused locally, as before, now as invalid_integration, before any upload or request. Covered by the tests.
  • Every other send: the same request as feat: send cc, bcc and attachments with emails()->send() #292, byte for byte, shown by the cross-version request diff.
  • Uncovered: PostHog reading the header into the send event's client. The launch train's real send at this head covers it. A miss mislabels attribution only, and the email still sends.
  • Switch-off: none. Nothing sends the option until an integration sets it.
  • Tier: critical (f: the stack's merge to main starts the SDK release)

💚 How did you test it?

  • Red on the base library: the new tests fail because the base refuses integration as an unknown option (invalid_form), 7 tests, 2 errors and 5 failures. After the merge with feat: send cc, bcc and attachments with emails()->send() #292, the invalid-integration case on a send with files failed until the option was checked before the upload.
  • Green on this head: the same tests pass with every assertion, none skipped.
  • Lane gate on PHP 8.4 in Docker at this head, the CI commands: composer validate --strict, PHPUnit (OK; the skips are the existing environment skips), the compliance adapter tests, PHPCS on the changed files, composer api:check (snapshot up to date: send() takes an array, so its signature is unchanged).
  • CI green on this head: PHPUnit on PHP 8.2 to 8.5, the framework jobs, PHPCS, public API, compliance.
  • Cross-version: the same send through feat: send cc, bcc and attachments with emails()->send() #292's head and this head with a recording transport. Without the option the request is identical; with it, this head adds only X-PostHog-Integration and feat: send cc, bcc and attachments with emails()->send() #292 refuses the option locally.
  • Test seam: Emails::send() with the HTTP transport stubbed, asserting the request's headers and body.
  • Live run: owed at this head. It runs in the email launch train, one real send per SDK gate branch to a PostHog inbox, before the merge. An earlier head of this PR passed a real send on US Cloud: PostHog answered 202, the request carried X-PostHog-Integration: posthog-php-mailer/4.20.0, and Emails::get() read it back as delivered.

📝 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

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built by Claude Code (Opus 5.5) in a T3 Code stack thread, and taken over in place by a second stack thread that merged #292 and main forward. Hold as a draft: it releases only together with the other SDKs' email send.

Question Answer Who Where
Where does the marker go? One X-PostHog-Integration header per send, from a per-send option; the User-Agent stays the same 👍 Accepted default https://github.com/PostHog/michaelr-context/issues/39
What does a bad value do? Fails locally as invalid_integration with the field integration, like every other bad option, before any upload 🤖 Agent this PR
How does this branch meet #292 and main? Merge #292's head forward here, never push #292; #292 now carries main 🤖 Agent https://github.com/PostHog/michaelr-context/issues/42

Involvement: no grilling for this PR; reviews: one GPT-6.1 Sol round (no findings) and PostHog Review (no findings) on the first head, a qa-swarm panel (round 1 with 10 lenses: no findings in this PR's code, the rest belonging to #292 and handed to its owner) and a clean final read of the #292 merge; proof captured (red, green, lane gate; live run in the launch train).

🤖 Generated with Claude Code

Silthus and others added 29 commits November 2, 2026 16:14
Add Client::emails() and PostHog::emails(). send() queues one email from
a template made in PostHog (template_id, template_data) or from content
the app rendered (template_key, subject, html and/or text, from,
reply_to, capture_engagement), as the flat body of
POST /api/projects/@current/messaging/send/email/. get() reads the
status at /messaging/emails/{message_id}/.

The secret key goes only to the PostHog Cloud app host or app_host,
every attempt carries one idempotency key, and transient failures retry
with backoff and Retry-After. Failures throw EmailError with PostHog's
code and a Resend-style name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A constant expression default put posthog-php/<version> into the public
API snapshot, so the release's version bump would fail composer
api:check.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also say in the README example that get() answers not_found for a few seconds after the send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measure the retry wait from its start instead of in microseconds since the
epoch, read an HTTP-date Retry-After without getTimestamp(), which throws
after 2038 on 32-bit PHP, and clamp both Retry-After forms the same way.

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

Also split the test's fake monotonic timer from microseconds, so it stays an
int on 32-bit PHP like the real hrtime().

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with template_key takes cc and bcc (one address or a list) and
attachments (filename, content, content_type, content_id). PHP strings
are bytes, so content is the file itself and the client encodes it as
Base64 once, before the retry loop. Empty lists are left out. An
attachment the client can't send throws invalid_attachment with a field
such as attachments__0__content, and never repeats the content. A send
with template_id refuses all three fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an empty reply_to out

A bad email option, such as a zero timeout, passed the Client
constructor and threw at the first emails() call. The constructor now
builds the email service once, so it throws there, as it does for
flag_definition_cache_provider. An empty migrated_from, such as an
unset env var, counts as left out, like app_host.

An empty reply_to list is left out instead of refused, as posthog-node
and posthog-python do. The huge Retry-After test now expects literals
instead of the clamp formula.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls-cc-bcc-attachments

# Conflicts:
#	lib/Emails.php
#	test/EmailsTest.php
…hrough names

email_request_timeout_ms and email_request_max_retries were cast with
(int), so 'two' became zero retries and '15seconds' a 15 ms timeout.
They now take an integer or an integer string, and anything else throws
InvalidArgumentException when the client is built.

The inline comments become names: forwardOnlyClock(),
wholeEpochSecondsAsFloat(), httpDateInUtc(),
longestRetryAfterSecondsAnIntHoldsInMs(), onOneLogLine() and
NO_EXPECT_100_CONTINUE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-8 text names its field

A file's content_type or content_id passed as null is left out, like a
null top-level option. A filename, content_type or content_id that
isn't UTF-8 throws invalid_attachment with its field, not a field-less
invalid_form from json_encode. The README, the send() docblock and the
change intent say to pass base64_decode($content) when the caller
holds Base64, so a Resend payload isn't encoded twice. The test of a
server refusal goes: it checked only the generic error mapping that
testAnErrorKeepsPostHogsCodeAndFields covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proof gate's judge found explanatory comments left after b2a1c5c:
the docblocks of waitToRetry() and resolveAppHost(), prose after two
@return types, two test class docblocks, FakeTimer's notes and the
mocks' headers. They go. Two tests name what their comment said:
$closedOnceTheFirstAttemptIsInFlight and $aYearOrWhatA32BitIntHoldsInMs.
Type tags and phpcs directives stay. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mailer integration passes 'integration' => '<name>/<version>' to
emails()->send(), and the client sends it as the X-PostHog-Integration
header of that send. The value is checked locally and a bad one sends
nothing. The User-Agent stays the same.

Ticket: PostHog/michaelr-context#42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Nov 5, 2026
Silthus and others added 22 commits November 9, 2026 22:37
…after uploads

- upload_failed takes its name before the status mapping, so a storage 401 is no missing API key.
- A test now covers a shutdown after the last upload, the only case the check before the send stops.

Refs PostHog/michaelr-context#84

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

A large file needs longer to transfer, not to connect, so a stalled storage host now fails after the request timeout instead of minutes.

Refs PostHog/michaelr-context#84

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs PostHog/michaelr-context#84

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

A 404 from the upload route means either a PostHog without the route or a project that can't send email from code, and the SDK can't tell them apart. It now throws PostHog's answer, `not_found` on `attachments` with its reason, instead of `field_not_accepted`, which points at the wrong fix for the second cause. Nothing is sent.

Refs PostHog/michaelr-context#84

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

Refs PostHog/michaelr-context#84

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

- The local 40 MB check counts the subject's folded encoded words, as
  PostHog's estimate now does, so a send that passes locally is never
  refused after its uploads (PostHog/michaelr-context#84).
- A storage 429 is retried like a storage 500, 502, 503 or 504, and the
  docs say so.
- The test storage refuses bytes that don't match the size and checksum
  PostHog signed, and each upload slot is signed for its own file.
- The connect and transfer timeouts are proven over real HTTP instead of
  by the transport's arguments.
- Drop field_not_accepted from the local refusal codes; nothing raises it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Skip unless the probe timed out, so a stack that refuses a full listen
  queue at once can't pass the test whatever the connect timeout is.
- Drop the prose docblocks the round added; the names carry the meaning.

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

Each subject case finds the largest file that fits beside it, and one
byte more is refused: 42 ASCII bytes add 75, 43 add 150, 21 two-byte
characters add 75, 22 add 150. An empty subject never reaches the
estimate: the SDK refuses it before any request (PostHog/michaelr-context#84).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The empty subject is already refused in the argument errors, and the
12-byte subject case covers one byte more than fits.

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

The real-HTTP storage answered 200 to any body. It now answers 400
BadDigest when the body doesn't match `x-amz-checksum-sha256`, as real
storage does, and a test proves the send stops there.

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

The upload tests asserted the signed URL text and header strings. The
storage fakes now refuse what real storage refuses (a URL or signed
header that doesn't match the slot, bytes that miss the signed checksum)
and the PostHog fake refuses another route or key, so the tests assert
outcomes: the email is queued, storage holds the file's bytes, and the
secret key never reaches storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of asserting the request order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fuse a second auth

Storage ignores unsigned headers, so blanking curl's form type changed nothing
a caller can see. Both storage fakes now refuse a presigned PUT that also carries
an Authorization header, as S3 does, instead of tests reading header strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without text, PostHog sends the text it derives from the html, and cdp-api
counts it, so the local estimate counts the html twice. An upload URL that
isn't https is refused before any upload, unless the app host is plain http
for local development.

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

A storage 429 that outlasts the retries is now rate_limit_exceeded, like every
other 429. The test fake keeps what PostHog queued (one email per idempotency
key), what storage took, and refuses a request without the secret key, so the
attachment tests assert the result, the stored files and the queued email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… emails, not requests

The API fake answers the upload route from what storage holds, and the local
server records each answer's status, so tests read what storage took and what
PostHog queued rather than the requests the SDK made.

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

A 3xx answer to an upload PUT fails that attachment without quoting storage's
answer, which can carry the redirect URL, and sends nothing. curl already
doesn't follow; the test proves a following client would leak the file. The
error code matches the attachments spec and the other SDKs.

Refs PostHog/michaelr-context#84

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into feat/emails-integration-option

prepareSend() returns both the files to upload and the integration.

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

Brings in tracking and sensitive (#308) beside cc, bcc and attachments:
the code form takes all of them, and capture_engagement is left out
unless the caller sets it.

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

The integration is checked with the other arguments, before the client
asks PostHog which files it needs, so a bad marker never leaves an
uploaded file behind.

Refs PostHog/michaelr-context#42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes #308's sensitive and tracking beside cc, bcc and attachments: the code
form accepts all of them, the docs describe both, and the two tests that
expected capture_engagement true by default now expect it left out, as main
sends it only when set. A send with files and an invalid sensitive or tracking
option uploads nothing.

Refs PostHog/michaelr-context#84

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 ec29f52: 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.

@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 25bcacd: no findings.

Base automatically changed from feat/emails-cc-bcc-attachments to main November 13, 2026 07:01
@Silthus
Silthus marked this pull request as ready for review November 13, 2026 07:10
@Silthus
Silthus requested a review from a team as a code owner November 13, 2026 07:10
@veria-ai

veria-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

PR overview

The PR adds an integration option for naming the sending integration in the email workflow.

One issue remains open in attachment uploads: a malicious uploads API response can direct PUT requests containing attachment bytes and response-selected headers to internal HTTPS services whose certificates the application trusts. HTTPS enforcement and disabled redirects do not constrain the initial destination, leaving a destination-validation gap. No issues have been addressed yet.

Open issues (1)

Fixed/addressed: 0 · PR risk: 4/10

Reviews

Comment thread lib/Emails.php
$scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : '';
$headers = $slot['headers'] ?? null;
if (
!in_array($scheme, ['http', 'https'], true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Upload destinations are not constrained

These checks accept any HTTPS upload destination, including loopback/private services; a malicious uploads API response can therefore direct PUTs containing attachment bytes and response-chosen headers to internal endpoints whose certificates the application trusts. As defense in depth, restrict uploads to operator-approved storage hosts and validate the actual resolved connection addresses, with explicit exceptions for private/self-hosted storage; HTTPS and disabled redirects do not constrain the initial destination. (curl.se)

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