Repository navigation
Conversation
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>
…ls-cc-bcc-attachments
…-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>
…ls-cc-bcc-attachments
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>
…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>
There was a problem hiding this comment.
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.
…ation-option Ticket: PostHog/michaelr-context#42 # Conflicts: # test/EmailsTest.php
PR overviewThe 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
|
| $scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : ''; | ||
| $headers = $slot['headers'] ?? null; | ||
| if ( | ||
| !in_array($scheme, ['http', 'https'], true) |
There was a problem hiding this comment.
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)
💡 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-phpcall, so PostHog can't count framework sends per integration.emails()->send()now takes one more option,'integration' => '<name>/<version>', such aslaravel/1.4.0. The client checks it against^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$and sends it as theX-PostHog-Integrationheader of that send, on every retry. A bad value fails locally withinvalid_integrationand 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
mainitself, and this branch merges #292's head forward, so this PR's diff against #292 is only theintegrationoption.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 passesposthog-php-mailer/<version>, and the Laravel driver will passlaravel/<version>through that transport. A break here is a value one of those accepts and this one refuses, or the reverse. The sdk-specsemailsspec doesn't coverintegration, so nothing here diverges from it. Ticket: https://github.com/PostHog/michaelr-context/issues/42Impact
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
mainstarts that release and makesintegrationpublic APIintegration, in every region: on feat: send cc, bcc and attachments with emails()->send() #292 the send is refused locally asinvalid_form; here it carriesX-PostHog-Integrationon every attempt. Covered by the tests on both forms and on retries, and by the cross-version request diff.integration: refused locally, as before, now asinvalid_integration, before any upload or request. Covered by the tests.client. The launch train's real send at this head covers it. A miss mislabels attribution only, and the email still sends.mainstarts the SDK release)💚 How did you test it?
integrationas 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.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).X-PostHog-Integrationand feat: send cc, bcc and attachments with emails()->send() #292 refuses the option locally.Emails::send()with the HTTP transport stubbed, asserting the request's headers and body.X-PostHog-Integration: posthog-php-mailer/4.20.0, andEmails::get()read it back asdelivered.📝 Checklist
If releasing new changes
pnpm changeto 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
mainforward. Hold as a draft: it releases only together with the other SDKs' email send.X-PostHog-Integrationheader per send, from a per-send option; the User-Agent stays the sameinvalid_integrationwith the fieldintegration, like every other bad option, before any uploadmain?mainInvolvement: 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