Skip to content

feat: send cc, bcc and attachments with emails()->send() - #292

Open
Silthus wants to merge 53 commits into
mainfrom
feat/emails-cc-bcc-attachments
Open

Silthus wants to merge 53 commits into
mainfrom
feat/emails-cc-bcc-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Merge gate: node-store (#77) and django-facade (#78) live in both regions, the allowlist at * in both regions, and a real send through this SDK version with a real project key, delivered and confirmed by Michael. Then the repo's normal release.

💡 Motivation and Context

Problem: a PHP app moving its email from Resend to PostHog can't send a receipt with a PDF, or copy finance on an invoice. emails()->send() takes rendered html/text only (#288).

Change: a send with template_key also takes cc, bcc and attachments. The caller passes each file's bytes. The SDK hashes each file, asks PostHog where to upload the files it doesn't hold yet, uploads them straight to PostHog's file storage, and then sends the email with references to the files. Ticket: PostHog/michaelr-context#84.

PostHog::emails()->send([
    'to' => 'user@example.com',
    'template_key' => 'receipt',
    'subject' => 'Your receipt',
    'html' => $html,
    'cc' => ['manager@example.com'],
    'bcc' => 'finance@example.com',
    'attachments' => [
        ['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)],
        ['filename' => 'logo.png', 'content' => $png, 'content_id' => 'logo'], // <img src="cid:logo">
    ],
]);

Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with migrated_from.

Caution

Merge risk: high, one-way door. Merging the change intent starts a release, and a release makes upload-then-send the public contract for files: a sent email, with its copies and files, can't be taken back. The fields are optional: a shadow diff at head shows a send without them goes out byte for byte as before. Don't merge until the merge gate on the first line holds. The server half is the upload route (PostHog/posthog#131664), sends by reference (PostHog/posthog#131763) and the cdp attachment store chain (#131638, #131833, #131944). Switch-off: the server's per-team allowlist; a team off it gets a synchronous 400 field_not_accepted on attachments, never a silent drop. A PostHog without the upload route answers 404 not_found on attachments, and nothing is sent.

Changes

  • cc and bcc: one address or a list. A string becomes a one-item list. An empty list is left out, as posthog-node and posthog-python do.
  • attachments: a list of ['filename' => ..., 'content' => ..., 'content_type' => ..., 'content_id' => ...]. content is the file's bytes, such as file_get_contents() returns. A null content_type or content_id counts as left out. An empty list is left out.
  • How files travel, once per send, before the retried send:
    1. The SDK hashes each file with SHA-256 (hash(), the standard library) and asks POST /api/projects/@current/messaging/attachments/uploads/ about each distinct file.
    2. A file PostHog answers present is skipped. Each upload file goes in a plain PUT to the returned URL with the returned headers and no secret key. An upload URL that isn't https is refused before any upload (api_error on the file's field, with the fix "Fix the file storage's URL", and no URL in the error), unless the app_host is plain http://, the existing way to run against a local PostHog.
    3. The email goes out with attachments: [{sha256, filename, content_type?, content_id?}]. The same file attached twice uploads once.
  • Limits checked before any request: at most 10 files (too_many_attachments), and at most 40 MB per message after Base64 encoding, about 29 MB of files (message_too_large). The estimate counts each file and each body at its Base64 transfer size (without text, the html twice, since PostHog sends the text it derives from it), the subject at its folded encoded words (ceil(UTF-8 bytes / 42) * 75), plus 64 KiB for headers, as the server does. There is no limit per file. Both refusals carry why, fix and the field. The README and the send() docblock name the limit.
  • Refused before any request as invalid_attachment, with a field such as attachments__0__content: a non-list, a file that isn't an array, a path (Resend style), an unknown key, a missing filename, missing or empty content, a non-string content_type/content_id, or text that isn't UTF-8. No error repeats the file's content.
  • A failed upload sends nothing. The storage's answer surfaces as attachment_upload_failed, such as It answered HTTP 403 AccessDenied: Request has expired., with the file's field. A storage 429 that outlasts the retries is named rate_limit_exceeded, like every other 429. A network error or a storage 429, 500, 502, 503 or 504 retries within the email retry budget and honors Retry-After, since an upload puts the same bytes at the same key. A refusal of the upload request (message_too_large with files__1__size, say) names the attachment it refers to, attachments__2__content. The daily upload quota (upload_quota_exceeded) fails at once. A client shut down mid-flow stops before the next upload or the send with client_closed. An upload never follows a redirect: a 3xx fails that attachment with attachment_upload_failed, naming the attachment but never the URL, and nothing is sent. The PHP transport already set curl not to follow; this change stops the error from quoting storage's answer, which can carry the redirect URL. The code is attachment_upload_failed, as the attachments spec and the other SDKs name it.
  • An upload-route 404 sends nothing and throws PostHog's own answer, not_found on attachments, with its reason. Both a PostHog without the upload route and a project that can't send email from code answer 404, and the client can't tell them apart.
  • Uploads get more time to transfer: the request timeout plus 1 ms per 100 bytes of the file. Connecting still gets only the request timeout, so a stalled storage host fails fast.
  • README, the send() docblock and its array shape, and a minor change intent.

Divergence from the spec and the other SDKs:

  • None on the estimate: the client counts as the attachments spec (revised 2026-11-10 to count the subject and the derived text) and node-store's estimate do, so the client never passes a message the server refuses. Counting the html again for a missing text is an upper bound, since the derived text is never longer than the HTML.
  • None on https: the spec (revised 2026-11-10) and posthog-go upload only to https URLs. Go's opt-in is AllowInsecureAppHost; PHP has no such flag, and a plain http:// app_host already means local development, so it is the opt-in here and no option was added.
  • Local refusals reuse the server's codes (too_many_attachments, message_too_large), so an app handles both the same way.
  • posthog-node and posthog-python send a string content as already Base64. PHP has no separate bytes type, so a string is always the file. A caller holding Base64 passes base64_decode($content).

💚 How did you test it?

  • CI at 569df7a: PHPUnit on PHP 8.2 to 8.5, phpcs, the public API check, the compliance runs and the framework runs; see the checks below.

  • Lane gate at 569df7a, on PHP 8.5 in Docker: the full suite (2769 tests) passes except PackageArchiveTest, which needs the git directory inside the container and runs in CI. Also composer api:check and phpcs on the changed files.

  • Test seams: emails()->send() on a real Emails with an injected transport in place of HTTP. Its storage keeps the URL and headers it signed for each upload slot, as real storage does: a PUT to another URL or with other signed headers gets 403 SignatureDoesNotMatch, and bytes that don't match the signed size and checksum get 400 BadDigest. A PUT that also carries an Authorization header gets 400 InvalidArgument, as S3 answers a second auth, and a send that names a file storage doesn't hold gets PostHog's 400 attachment_not_uploaded. The fake PostHog refuses a request without the secret key, answers the upload request from what storage holds (a slot to sign for a file it lacks, present for one it has), and queues one email per idempotency key. The tests assert outcomes: the result, what storage holds and how often it took each file, the queued email, and the error. The tests cover:

    • the upload request, the file stored as signed without the secret key, and the queued email's references;
    • an upload URL that isn't https refused before any upload, naming the attachment and holding no URL, and a plain http:// app host uploading over plain HTTP;
    • present files skipped, the same file uploaded once, and a retried send that doesn't upload again;
    • each storage refusal (bytes that don't match the signed checksum, 400 BadDigest, 401, 403 expired, 501) sending nothing and quoting the storage; storage 429, 500, 502, 503, 504 and network errors retried, and a 429 that outlasts them named rate_limit_exceeded; Retry-After; and a shutdown after the upload request, after an upload and after the last upload;
    • each upload-route refusal mapped to its attachment field;
    • the route's 404 reported as PostHog's own not_found on attachments, with and without a JSON body, and each upload answer the client can't follow (a non-http URL, a non-PUT method, a header with a line break, a missing status);
    • an HTML-only send that fits with the HTML counted once but not twice refused before any request;
    • over real HTTP, storage that answers the PUT with a 307 to a second server at an http:// URL: the second server receives nothing, nothing is sent, and the error names the attachment but neither URL. Letting curl follow redirects fails it, and so does quoting storage's answer, which carries the URL;
    • the 10-file and 40 MB boundaries, with the subject counted as node-store counts it: beside each subject the largest file that fits is uploaded and one byte more is refused. A 12-byte or 42-byte ASCII subject leaves 29,182,812 bytes of files, a 43-byte one 29,182,758; 21 two-byte characters leave 29,182,812 and 22 leave 29,182,758, so the subject counts in UTF-8 bytes. An empty subject is refused before any request. Counting no subject, or counting characters, fails these cases.

    EmailsClientTest runs the flow over real HTTP against two local servers, an API and a storage host; the storage refuses bytes that miss the signed checksum with 400 BadDigest, and the send stops there. Storage there accepts only the signed path, content-length and checksum, and refuses a second auth as S3 does; the API accepts only an upload request with the secret key that names each file's hash and size. The tests assert the result, the bytes storage took and the emails the API queued, never the requests themselves; sending the secret key to storage fails it. Two tests prove the upload timeouts there: storage that answers after 1.5 s still takes a 200 KB file under a 1 s request timeout, and a storage host whose listen queue is full fails the upload with network_error at the 1 s connect timeout, not the 6 s transfer timeout, with no send. Setting the connect timeout to the transfer timeout fails the second (6 s), and dropping the extra transfer time fails the first.

  • Live run at 569df7a against a real S3-compatible store (SeaweedFS), with upload URLs presigned by the AWS SDK the way node-store signs them (content-length and x-amz-checksum-sha256 signed, 15 minutes). The PostHog API side is a local server answering the upload contract.

    Case Result
    One new 29 MB file with cc and bcc Uploaded in one PUT. The store holds 29,000,000 bytes with the SHA-256 checksum, the stored bytes hash to the file's hash, and the email queued with the reference
    The upload URL signed for another file's checksum attachment_upload_failed, HTTP 400 BadDigest, field attachments__0__content. One API request, no send, nothing stored
    An expired upload URL attachment_upload_failed, HTTP 403 AccessDenied: Request has expired. One API request, no send, nothing stored
    The upload URL answers 307 (a real Caddy) to a valid presigned PUT for the same file attachment_upload_failed, status 307, field attachments__0__content, naming receipt-e.pdf and no URL. One PUT to the redirector, one API request, no send, nothing stored. A control run with curl set to follow redirects stored the file at the target and sent the email
  • Cross-version at 569df7a: this SDK against today's PostHog Cloud in both regions, which predates the server half. A send with attachments asks the upload route, gets 404 Endpoint not found, throws PostHog's answer, not_found on attachments with the message Endpoint not found., and sends nothing. A send with only cc reaches today's send route in the same shape. With a dummy key it stops at authentication, so its validation comes from today's server code: the server takes the list, and a team it doesn't accept the field for gets field_not_accepted, never a silent drop.

  • Shadow diff at 569df7a against main (8726c6f): the same email calls on each side against a recording server with the same replies. The calls are a template send, a code send with a reply_to list, a retried send with a generated key, a 400 refusal, a 429 quota, a status read and an argument check. The requests, results and errors are identical.

  • Gaps:

    • No run against a real PostHog yet: the server half isn't live. The opening slice of PostHog/michaelr-context#84's map proves the full path end to end.
    • No 32-bit PHP run at this head; the tests don't depend on integer width beyond the 40 MB sum, which stays far below 2^31.
  • Review: a design pass before the rework, and a qa-swarm panel at d87ef8b (fresh eyes on Claude; correctness, risk, design, anti-overengineering, systems and sweep on GPT-6.1 Sol). Fixed in 7a720ed:

    • a shutdown mid-flow still uploaded and sent;
    • storage Retry-After was ignored;
    • attachment_upload_failed took a permission name from a 403;
    • an upload answer without a status raised a PHP warning;
    • the size estimate went through a parallel list.

    Round 2 on the fixes found three minor issues, fixed in 55089ad and b7affe7: a storage 500, 502 or 504 wasn't retried, a storage 401 was named missing_api_key, and no test guarded the shutdown check before the send. A final read at b7affe7 found nothing. PostHog Review at d87ef8b found the same shutdown, Retry-After and missing-status issues plus the storage 500 retry. At b7affe7 it found that a large upload also stretched curl's connect timeout, fixed in 2d96d4d; a curl run against an unreachable address gave up after the 300 ms connect timeout with a 5 s transfer timeout. Each thread is answered. A final read at 2d96d4d found nothing, and PostHog Review at 2d96d4d found nothing. ff26044 only names the retried storage statuses in the README and docblock; its final read and PostHog Review found nothing. 2ff3d91 reports an upload-route 404 as PostHog's own not_found on attachments, in place of field_not_accepted, as the attachments spec's Compatibility section now asks; its final read and PostHog Review found nothing. 5934ddf drops two inline comments; its final read found nothing.

    Round 3, a full qa-swarm at 5934ddf: the seven lenses plus four team perspectives (the author's lens, SDK spec conformance, engineering systems thinking and an XP reviewer). Fixed in 44987c2: the estimate now counts the subject, as the attachments spec does since 2026-11-10 (found while checking the spec for this round); a storage 429 was retried but not documented; field_not_accepted sat unused in the local refusal codes; the test storage accepted any bytes and two upload slots carried another file's checksum; the timeout test checked transport arguments instead of behavior. Kept as is: upload_quota_exceeded stays named rate_limit_exceeded, as the sdk-specs error names table has it for every 429 but quota_limited. Round 4 on the fixes found a stale description, a connect test that could pass on a stack that refuses a full queue at once, and prose docblocks, fixed in 1d9e602. 147e4c7 pins the subject term to node-store's cases (42 and 43 ASCII bytes, 21 and 22 two-byte characters); round 5 on it found two tests repeating another's input, dropped in 694a980. f257fe0 makes the real-HTTP storage refuse bytes that miss the signed checksum, as the gate's judge asked. The judge also failed tests that asserted header strings and signed-URL text; 90ca165 replaces them with outcomes against storage fakes that enforce their signature, Final reads 9 and 10 found two more such assertions (the request order, the secret in storage's headers); 379fa80 and 7bfc98b replace them with a fake API that refuses a send of a file storage doesn't hold and storage that refuses a second auth. 7bfc98b also drops the blank Content-Type on uploads: storage ignores unsigned headers, the spec puts no content type on uploads, and node-store never reads the stored label. Final read 11 asked to restore it; rejected for that reason, under the standing rule.

    Round 6, a full qa-swarm at 1041d8b with the same eleven lenses, after the attachments spec's 2026-11-10 revision (from PostHog Review on posthog-go#408): 1041d8b counts the derived text and uploads only over https. Fixed in 62bcc89: a storage 429 that outlasted the retries was named application_error, and the remaining attachment tests asserted request order and header strings; the test fake now keeps what PostHog queued and what storage took, and refuses a request without the secret key. A final read at 62bcc89 found nothing. The gate's judge there still found a test reading the transport's request body and tests counting requests; in 27df80d the fake answers the upload route from what storage holds, and the tests read the stored files and the queued emails instead. Kept out of this PR: shutdown doesn't wait for a send in its retry backoff, as the emails spec asks; waitToRetry is unchanged from main, so it's a follow-up. The risk lens graded the merge risk high. 569df7a adds the attachments spec's revision from the security lens on posthog-js#5514: an upload never follows a redirect. It also renames upload_failed to attachment_upload_failed, the spec's name and the other SDKs'. Final read 14 on it found nothing.

Superseded: inline Base64 design, tested at faee0cf and 14ec0dc
  • Test seams: emails()->send() on a real Emails with an injected transport. The tests assert the request body (cc as a list, bytes as Base64, content_type/content_id passed through), the same body on the retry, empty lists left out, each invalid_attachment case with its field and without the content, null metadata left out, and the template-form refusals.

  • Local gate at faee0cf: the full PHPUnit suite (2677 tests) on PHP 8.4, EmailsTest on 32-bit PHP 8.2 (123 tests), composer api:check, phpcs (PSR12). CI runs the suite on PHP 8.2 to 8.5, phpcs, the public API check and the compliance runs.

  • Cross-version, live, at 14ec0dc: against a local PostHog on today's server (PostHog/posthog#127123 and #127122), a send with cc, bcc and two files got a 400 invalid_input with getField() attachments, and nothing went out. A plain send to the same server delivered to maildev.

  • Live run at faee0cf against the server half: Django at PostHog/posthog#127988 on #127123, and cdp-api with the hog flow and email workers at the cdp chain #127985, #127995, #127986, delivering over SMTP to a local maildev. A PHP script built the client as a user does, with app_host pointed at a logging proxy.

    Case Result
    to, two cc, one bcc, an 83-byte PDF and a 70-byte PNG with content_id logo 202; one message, envelope to to, both cc and the bcc; the Cc header lists both, no Bcc header
    The files in maildev both byte-identical to the bytes passed (SHA-256 match); the PNG inline with content ID logo, the PDF as an attachment
    A .exe file 400 invalid_request, field attachments__0__filename, with why and fix
    Eleven cc addresses 400 invalid_request, field cc
    A cc with a display name 400 invalid_request, field cc__0
    Over 512 KB of files 400 invalid_request, field attachments
    A path file, a contentType key invalid_attachment with the field, and no request reached the proxy
    get() on the first send 404 for a few seconds, then sent
  • Shadow diff, base #288 at b2a1c5c against 14ec0dc: the same calls on each checkout against a recording server with the same replies. Capture, identify, alias, group identify, flags and flush, and a template send, a code send with a reply_to list, a send with a generated idempotency key and a status read. After normalizing UUIDs, timestamps and ports, the requests and return values are identical.

  • Review: a full qa-swarm panel at 7cefa76: fresh eyes on Claude, and correctness, risk, design, anti-overengineering, systems, sweep and three team perspectives (Michael's, SDK spec conformance, XP reviewer) on GPT-6.1 Sol. Seven lenses found nothing. Fixed in 14ec0dc: the docs now say to pass base64_decode($content) when you hold Base64, such as from a Resend payload, which the client can't tell from bytes; a null content_type or content_id counts as left out; a filename, content_type or content_id that isn't UTF-8 throws invalid_attachment with its field, not a field-less invalid_form; a test that only re-checked the generic error mapping went; comments went. The risk lens graded the merge risk high, so the shadow diff ran. Rejected: a spec extension for code-content sends, which the lens asked for no code change and belongs to the spec owners. Round 2 (fresh eyes and risk on the fix diff) found no code defect; it asked for this description and the live run.

📝 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)

Written by Claude Opus 5.5 (claude-opus-5-5) in Claude Code, for Michael, as the PHP slice of the attachments work (PostHog/michaelr-context#84). It moves this PR from inline Base64 to upload by hash, as the server contract changed.

Question Answer Who Where
How do files reach PostHog? Hash, upload the missing files to file storage, then send references. This replaced inline Base64 content 👤 Human comment and PostHog/michaelr-context#84
Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice 👍 Accepted default comment
What is an attachment's content in PHP? Always the file's bytes; a caller holding Base64 passes base64_decode($content) 🤖 Agent comment
What does an empty cc, bcc or attachments list do? It is left out and the email sends, as in posthog-node and posthog-python 🤖 Agent comment
What does a local refusal report? The server's codes, too_many_attachments and message_too_large, named validation_error 🤖 Agent comment
How are the bodies counted toward the 40 MB? At Base64 transfer size, as node-store counts them, not the spec's raw size 🤖 Agent comment
How long may an upload take? The request timeout plus 1 ms per 100 bytes 🤖 Agent comment
What does an upload-route 404 throw? PostHog's own answer, not_found on attachments with its reason, and no send. It replaced field_not_accepted, which points at the wrong fix when sending email from code is off for the project 🤖 Agent comment
What happens to the same file attached twice? Uploaded once, attached twice 🤖 Agent comment
What does an empty file do? Refused before any request as invalid_attachment 🤖 Agent comment
Which name does attachment_upload_failed carry? service_unavailable for a storage 5xx, application_error otherwise 🤖 Agent comment
What stops a send after the client shuts down mid-flow? A check before each upload and before the send, throwing client_closed 🤖 Agent comment
Which storage faults does an upload retry? 429, 500, 502, 503, 504 and network errors; the send itself still retries only 503 🤖 Agent comment

Involvement: no questions asked in this rework. Reviews: a design pass, a seven-lens qa-swarm panel, a second round on the fixes, a full qa-swarm with four team perspectives on the final code, a round on its fixes, final reads, and PostHog Review. Proof captured: a live run against an S3-compatible store, a cross-version run against today's PostHog Cloud, and a shadow diff, all at head.

🤖 Generated with Claude Code

Silthus and others added 21 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>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-10T09:26:12.582076+00:00
Duration: 115145ms

⚠️ 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 ✅ 526ms
Format Validation.Event Has Lib Properties ✅ 524ms
Format Validation.Distinct Id Is String ✅ 527ms
Format Validation.Token Is Present ✅ 527ms
Format Validation.Custom Properties Preserved ✅ 526ms
Format Validation.Event Has Timestamp ✅ 526ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 526ms
Retry Behavior.Retries On 503 ✅ 5850ms
Retry Behavior.Does Not Retry On 400 ✅ 2528ms
Retry Behavior.Does Not Retry On 401 ✅ 2530ms
Retry Behavior.Respects Retry After Header ❌ 6338ms
Retry Behavior.Implements Backoff ✅ 16259ms
Retry Behavior.Retries On 500 ✅ 5154ms
Retry Behavior.Retries On 502 ✅ 5637ms
Retry Behavior.Retries On 504 ✅ 5640ms
Retry Behavior.Max Retries Respected ✅ 16263ms
Deduplication.Generates Unique Uuids ✅ 36ms
Deduplication.Preserves Uuid On Retry ✅ 5642ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10856ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5644ms
Deduplication.No Duplicate Events In Batch ✅ 530ms
Deduplication.Different Events Have Different Uuids ✅ 528ms
Compression.Sends Gzip When Enabled ✅ 530ms
Batch Format.Uses Proper Batch Structure ✅ 527ms
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 ✅ 5647ms

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 ✅ 522ms
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 ✅ 519ms
Request Payload.Token In Flags Body Matches Init ✅ 518ms
Request Payload.Groups Round Trip ✅ 520ms
Request Payload.Groups Default To Empty Object ✅ 520ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 518ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 519ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 520ms
Request Lifecycle.No Flags Request On Init Alone ✅ 516ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 515ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 520ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 521ms
Retry Behavior.Retries Flags On 502 ✅ 622ms
Retry Behavior.Retries Flags On 504 ✅ 621ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 528ms

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-10T09:26:22.064784+00:00
Duration: 115528ms

⚠️ 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 ✅ 521ms
Format Validation.Event Has Lib Properties ✅ 523ms
Format Validation.Distinct Id Is String ✅ 524ms
Format Validation.Token Is Present ✅ 522ms
Format Validation.Custom Properties Preserved ✅ 524ms
Format Validation.Event Has Timestamp ✅ 522ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 524ms
Retry Behavior.Retries On 503 ✅ 5830ms
Retry Behavior.Does Not Retry On 400 ✅ 2526ms
Retry Behavior.Does Not Retry On 401 ✅ 2525ms
Retry Behavior.Respects Retry After Header ❌ 6332ms
Retry Behavior.Implements Backoff ✅ 16234ms
Retry Behavior.Retries On 500 ✅ 5141ms
Retry Behavior.Retries On 502 ✅ 5628ms
Retry Behavior.Retries On 504 ✅ 5631ms
Retry Behavior.Max Retries Respected ✅ 16246ms
Deduplication.Generates Unique Uuids ✅ 528ms
Deduplication.Preserves Uuid On Retry ✅ 5630ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5633ms
Deduplication.No Duplicate Events In Batch ✅ 527ms
Deduplication.Different Events Have Different Uuids ✅ 523ms
Compression.Sends Gzip When Enabled ✅ 522ms
Batch Format.Uses Proper Batch Structure ✅ 523ms
Batch Format.Flush With No Events Sends Nothing ✅ 519ms
Batch Format.Multiple Events Batched Together ✅ 510ms
Error Handling.Does Not Retry On 403 ✅ 2525ms
Error Handling.Does Not Retry On 413 ✅ 2525ms
Error Handling.Retries On 408 ✅ 5632ms

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 ✅ 545ms
Request Payload.Flags Request Uses V2 Query Param ✅ 519ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 524ms
Request Payload.Flags Request Omits Authorization Header ✅ 520ms
Request Payload.Token In Flags Body Matches Init ✅ 522ms
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 ✅ 520ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 520ms
Request Lifecycle.No Flags Request On Init Alone ✅ 517ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 508ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 527ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 523ms
Retry Behavior.Retries Flags On 502 ✅ 624ms
Retry Behavior.Retries Flags On 504 ✅ 623ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 524ms

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-10T09:26:16.130939+00:00
Duration: 116354ms

⚠️ 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 ✅ 23ms
Format Validation.Event Has Uuid ✅ 519ms
Format Validation.Event Has Lib Properties ✅ 520ms
Format Validation.Distinct Id Is String ✅ 520ms
Format Validation.Token Is Present ✅ 520ms
Format Validation.Custom Properties Preserved ✅ 520ms
Format Validation.Event Has Timestamp ✅ 522ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 520ms
Retry Behavior.Retries On 503 ✅ 5828ms
Retry Behavior.Does Not Retry On 400 ✅ 2522ms
Retry Behavior.Does Not Retry On 401 ✅ 2522ms
Retry Behavior.Respects Retry After Header ❌ 6329ms
Retry Behavior.Implements Backoff ✅ 16243ms
Retry Behavior.Retries On 500 ✅ 5626ms
Retry Behavior.Retries On 502 ✅ 5627ms
Retry Behavior.Retries On 504 ✅ 5627ms
Retry Behavior.Max Retries Respected ✅ 17043ms
Deduplication.Generates Unique Uuids ✅ 227ms
Deduplication.Preserves Uuid On Retry ✅ 5628ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10872ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5630ms
Deduplication.No Duplicate Events In Batch ✅ 525ms
Deduplication.Different Events Have Different Uuids ✅ 522ms
Compression.Sends Gzip When Enabled ✅ 521ms
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 ✅ 2524ms
Error Handling.Does Not Retry On 413 ✅ 2522ms
Error Handling.Retries On 408 ✅ 5629ms

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 ✅ 519ms
Request Payload.Token In Flags Body Matches Init ✅ 518ms
Request Payload.Groups Round Trip ✅ 519ms
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 ✅ 520ms
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 ✅ 622ms
Retry Behavior.Retries Flags On 504 ✅ 620ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 521ms

Silthus and others added 2 commits November 4, 2026 07:31
…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
@Silthus

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions for this PR:

  • 👍 Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice.
  • 🤖 What is an attachment's content in PHP? Always the file's bytes, which the client encodes as Base64. The alternative, sending a string as is like posthog-node and posthog-python do, can't work in PHP: a string is also how PHP holds bytes, so the two can't be told apart. A caller holding Base64, such as from a Resend payload, passes base64_decode($content), as the README and the send() docblock say since 14ec0dc.
  • 🤖 What does an empty cc, bcc or attachments list do? It is left out and the email sends, as posthog-node and posthog-python do, and as reply_to does since feat: send transactional email with emails()->send() #288's 8efe315.

Silthus and others added 2 commits November 4, 2026 07:44
…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>

@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 694a980: no findings.

…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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

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

…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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@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 90ca165: no findings.

Silthus and others added 5 commits November 10, 2026 07:13
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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@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 62bcc89: no findings.

… 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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@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 27df80d: no findings.

@Silthus
Silthus marked this pull request as ready for review November 10, 2026 09:09
stamphog[bot]

This comment was marked as outdated.

Comment thread lib/Emails.php Resolved
…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>
@stamphog
stamphog Bot dismissed their stale review November 10, 2026 09:23

New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.

@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@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 569df7a: no findings.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approved.

The risky areas have a clean current-head agent review and STRONG author familiarity, with targeted test coverage. No concrete unresolved review concerns or policy-defined refusal grounds are evident.

  • Author wrote 94% of the modified lines and has 1 merged PRs in these paths (familiarity STRONG).
  • PostHog Review (Standard) reviewed the current head: no findings.
  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 461L, 2F substantive, 1473L/8F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (1473L, 8F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ 569df7a · reviewed head 569df7a

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