Repository navigation
Conversation
Adds client.emails.send for a template made in PostHog (template_id) or content your code rendered (template_key, subject, html, text), and client.emails.get for the status of a sent email. Ports posthog-node and posthog-python's emails to Ruby: the same wire body, app host rules, idempotency key, retries and error names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child forked while another thread sent email inherited a positive in-flight count that no thread could settle, so shutdown waited for its whole timeout, or forever without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Net::HTTP treats a connection closed partway through a body as the end of the body, so a truncated 202 raised a non-retryable api_error. Turn EOF suppression off so the cut-off read raises a network error and the send retries with the same idempotency key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code form takes cc and bcc (one address or a list) and attachments: Hashes with filename, content, and an optional content_type and content_id. A binary String, such as File.binread returns, or a list of byte values is encoded as Base64 once, before the retry loop; any other String is taken as Base64 already and must look like it. Empty lists are left out, so a PostHog version without the fields still sends. A file the client can't send raises EmailError invalid_attachment with the field, before any request. Mirrors posthog-node (posthog-js#5514) and posthog-python#1172; the server holds the limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Explicit nil email options fall back to the defaults, and the client checks them before the worker, instance count and flags poller start. - app_host must be an http or https URL with a host. - A refusal whose body broke off keeps its status and Retry-After; a corrupt compressed body is a network error and retries. - skip_ssl_verification also applies to email requests. - Specs: real-socket server reads whole requests and runs without WebMock, the backoff spec waits on a queue instead of a fixed sleep, and the warning spec reads the logger output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-ruby-sync Compliance ReportDate: 2026-11-10T08:48:35.851415+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 9ms |
| Format Validation.Event Has Uuid | ✅ | 5ms |
| Format Validation.Event Has Lib Properties | ✅ | 5ms |
| Format Validation.Distinct Id Is String | ✅ | 8ms |
| Format Validation.Token Is Present | ✅ | 7ms |
| Format Validation.Custom Properties Preserved | ✅ | 6ms |
| Format Validation.Event Has Timestamp | ✅ | 8ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 6ms |
| Retry Behavior.Retries On 503 | ✅ | 5250ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 6ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 4ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8014ms |
| Retry Behavior.Implements Backoff | ❌ | 380ms |
| Retry Behavior.Retries On 500 | ✅ | 5114ms |
| Retry Behavior.Retries On 502 | ✅ | 5141ms |
| Retry Behavior.Retries On 504 | ✅ | 5116ms |
| Retry Behavior.Max Retries Respected | ❌ | 454ms |
| Deduplication.Generates Unique Uuids | ✅ | 14ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5163ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10337ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5119ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 15ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 8ms |
| Compression.Sends Gzip When Enabled | ✅ | 5ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 5ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 2ms |
| Batch Format.Multiple Events Batched Together | ❌ | 12ms |
| Error Handling.Does Not Retry On 403 | ❌ | 4ms |
| Error Handling.Does Not Retry On 413 | ❌ | 5ms |
| Error Handling.Retries On 408 | ✅ | 5112ms |
Failures
retry_behavior.does_not_retry_on_400
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.does_not_retry_on_401
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.implements_backoff
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
retry_behavior.max_retries_respected
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
batch_format.multiple_events_batched_together
Expected 1 requests, got 5
error_handling.does_not_retry_on_403
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
error_handling.does_not_retry_on_413
500, message='Internal Server Error', url='http://sdk-adapter:8080/capture'
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 9ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 7ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 7ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 6ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 6ms |
| Request Payload.Groups Round Trip | ✅ | 5ms |
| Request Payload.Groups Default To Empty Object | ✅ | 5ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 5ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 6ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 5ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 2ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 5ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 7ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 6ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 107ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 147ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 7ms |
Failures
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 1a2acc7: 1 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
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-ruby-async Compliance ReportDate: 2026-11-10T08:49:14.915528+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 10ms |
| Format Validation.Event Has Uuid | ✅ | 105ms |
| Format Validation.Event Has Lib Properties | ✅ | 109ms |
| Format Validation.Distinct Id Is String | ✅ | 108ms |
| Format Validation.Token Is Present | ✅ | 108ms |
| Format Validation.Custom Properties Preserved | ✅ | 108ms |
| Format Validation.Event Has Timestamp | ✅ | 106ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 109ms |
| Retry Behavior.Retries On 503 | ✅ | 5413ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2109ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2110ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8116ms |
| Retry Behavior.Implements Backoff | ✅ | 15713ms |
| Retry Behavior.Retries On 500 | ✅ | 5214ms |
| Retry Behavior.Retries On 502 | ✅ | 5215ms |
| Retry Behavior.Retries On 504 | ✅ | 5214ms |
| Retry Behavior.Max Retries Respected | ✅ | 15516ms |
| Deduplication.Generates Unique Uuids | ✅ | 114ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5213ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10417ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5216ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 114ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 108ms |
| Compression.Sends Gzip When Enabled | ✅ | 107ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 108ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 7ms |
| Batch Format.Multiple Events Batched Together | ✅ | 111ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2109ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2110ms |
| Error Handling.Retries On 408 | ✅ | 5213ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 109ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 108ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 108ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 109ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 107ms |
| Request Payload.Groups Round Trip | ✅ | 108ms |
| Request Payload.Groups Default To Empty Object | ✅ | 109ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 108ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 107ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 110ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 3ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 108ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 114ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 107ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 211ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 210ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 110ms |
Failures
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']
A reply whose body broke off is a network error again, so a cut-off 409 idempotency_key_in_flight retries as before. It now keeps the head's Retry-After: the retry waits for it, and one over 30 seconds raises at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building this PR:
|
The spec released the retry on a 50 ms timer, so it passed even when shutdown returned at once. It now holds the retry until it has seen shutdown still running, then releases it and expects true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pped Base64 - A String that isn't ASCII, such as a PDF read with File.read or UTF-16 text, raised a bare ArgumentError from the Base64 match. It now raises invalid_attachment with the field and the File.binread hint. - Base64 with line breaks, as Base64.encode64 writes it, is sent without them instead of being refused. - The retry spec checks both attempts carry the encoded file, and the Base64Check helper is inlined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing every line break let a text file read with File.read, such as "ABCD1234\nEFGH5678\n", pass as Base64, so the recipient got a garbled file. Line breaks are now removed only when the content is exactly what Base64.encode64 writes; other text is refused as invalid_attachment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog declined this pull request because two gates failed. The deny-list gate matched public_api: the change edits public_api_snapshot.txt and adds cc:, bcc: and attachments: to the public signature of PostHog::Emails#send in lib/posthog/emails.rb. The tier gate classified it as T2-never (230 lines, 5 files, cross-cutting feature), which a bot can't clear. The size and prerequisites gates passed, but they don't change the outcome.
The author should ask a human reviewer, such as a maintainer who owns the emails SDK, to review this pull request. The changes to the public API surface and the attachment content rules need that kind of review.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 109L, 2F substantive, 230L/5F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (230L, 5F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head df3ff92 |
# Conflicts: # lib/posthog/client.rb # lib/posthog/email_error.rb # lib/posthog/emails.rb # public_api_snapshot.txt # spec/posthog/emails_spec.rb
SDK performance: posthog-rubyBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
|
@Silthus PostHog Review is reviewing this pull request. |
…ize and digest The fake PostHog now refuses a malformed manifest and an unsigned send, and the fake storage takes a PUT only when its bytes match the signed size, checksum and digest. Tests read the stored files and the accepted emails instead of counting requests. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot requests The fake PostHog counts the bytes of each upload slot it issues against the team's daily quota, as the upload route does, so a file uploaded twice shows in the quota. The tests no longer read any request log. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
The fake storage adds each written file's bytes to a meter, as object storage reports bytes uploaded, so a file sent to storage twice fails the tests. The retry test now rests on the send succeeding and the stored file. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
…t by sleep's argument The attachment tests run on a fake clock that the client's waits advance. Fake storage answers 503 SlowDown with its Retry-After until that time has passed, so a client that retries early fails the send. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
… would carry When text is empty, PostHog derives it from the HTML and counts it, so the local estimate counts the HTML twice; the derived text is never longer. An upload URL without https now refuses on the file's field and names the file, never the URL, unless app_host is an http URL for local development. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
The local 40 MB estimate guards uploads, so it now runs only when the email has files, as in the Go SDK. Large HTML without files goes to PostHog as before. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't approve this pull request because two gates refused it. The deny-list gate flagged it for touching the public API: public_api_snapshot.txt changes, and PostHog::Emails#send gains the cc:, bcc: and attachments: parameters. The tier gate classified it as T2-never (873 lines across 6 files, cross-cutting, a new feature), a class that is never reviewed automatically.
The size gate passed, so splitting wouldn't change this outcome. Please ask a human reviewer to review it. The merge gate in your description is a good thing to point them to.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 339L, 3F substantive, 873L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (873L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 8e2bc63 |
Fake storage answers the PUT with a 307 to a second, plain-http storage. The second storage receives nothing, the attachment fails with attachment_upload_failed naming the file but not where storage pointed, and no email is sent. Net::HTTP doesn't follow redirects, so only the test is new. Refs PostHog/michaelr-context#83 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't approve this pull request, and the decision is final. Two gates refused it. The deny-list gate flagged it as touching public_api, because public_api_snapshot.txt changes and the PostHog::Emails#send signature gains cc, bcc and attachments. The tier gate classified it as T2-never, since it is a cross-cutting feature across 6 files and 895 lines.
The size gate passed on its own, so the change is not too large to review. The next step is to ask a human reviewer to take it, and the merge gate described in the PR body would still apply after that.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 339L, 3F substantive, 895L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (895L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 5659e3e |
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
Merge gate: node-store (PostHog/michaelr-context#77) and django-facade (PostHog/michaelr-context#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.
Tracked in PostHog/michaelr-context#83.
💡 Motivation and Context
Ruby apps that send transactional email through PostHog can't copy anyone in or attach a file:
client.emails.sendtakes one recipient and no files. An invoice, a statement or a ticket PDF has to go out through another provider. This PR addscc,bccandattachments, with files up to the size SES and Resend take: 10 files and 40 MB per email after Base64 encoding, about 29 MB of files.Files no longer ride inside the send request. The client uploads them straight to PostHog's file storage first and then sends their SHA-256 hashes, so a 29 MB file never crosses PostHog's API twice. This follows the server side in PostHog/posthog#131664 (upload route) and PostHog/posthog#131763 (references on sends).
Important
Release only after the merge gate above. Until then the send route refuses
attachmentswithfield_not_accepted, and a PostHog without the upload route answersnot_foundonattachments, before any email is queued.What it does
sequenceDiagram participant App as Ruby app participant SDK as posthog-ruby participant API as PostHog API participant S3 as File storage App->>SDK: emails.send(attachments: [...]) SDK->>SDK: SHA-256 and size per file, 10 files and 40 MB estimate SDK->>API: POST messaging/attachments/uploads/ {files: [{sha256, size}]} API-->>SDK: per file: present, or upload {url, method, headers} SDK->>S3: PUT bytes with exactly those headers, no API key (upload files only) SDK->>API: POST messaging/send/email/ attachments: [{sha256, filename, ...}] API-->>SDK: 202 message_idPostHog::EmailErrorwithnamevalidation_error,fieldattachments, and awhyandfix:too_many_attachments;message_too_large. The estimate is the server's: each file's Base64 size with line breaks, plus each body counted like a file (its UTF-8 bytes at their Base64 size with line breaks), with the HTML counted twice when there is no text, because PostHog derives the text from it and the derived text is never longer, plus the subject at 75 bytes per started 42 of its UTF-8 bytes (an empty subject counts nothing), plus 64 KiB for headers. With an empty subject and empty bodies, the largest single file that fits is 29,182,875 bytes. An email without files is left to PostHog, as before;invalid_attachmentnaming the field, such asattachments__0__path. An empty file is new here.presentand uploads nothing.uploadfile goes to itsurlwith the returned headers and noAuthorization. Storage checks the signed size and SHA-256, so corrupted bytes can't be stored.attachment_upload_failedwith storage's HTTP status and answer, such asFile storage refused the upload of invoice.pdf with HTTP 400 (BadDigest: …), so no email was sent.Itsnameisservice_unavailablefor a storage 5xx andapplication_errorotherwise, a storage 401 included, never one of PostHog's own categories such asmissing_api_key. A PUT that never reaches storage raisesnetwork_errornaming the file.not_foundonattachmentswith its reason (Endpoint not found.), and uploads nothing. A 404 without a JSON body, such as a proxy's HTML page, raises the samenot_foundonattachments. The same 404 comes from a project that can't send email from code, so the client doesn't guess between the two.api_erroron the file's field, such asattachments__0, naming the file but never the URL, with a fix for the file storage's URL. The one exception is the SDK's existing opt-in for plain HTTP:app_hostset to an http URL, as in local development. No new option. Uploads never follow redirects: storage answering a PUT with a 3xx refuses that file asattachment_upload_failed, naming the file and never where storage pointed, and nothing is sent.Retry-Afterwhen it sends one. ARetry-Afterover 30 seconds stops the retries and surfaces on the error asretry_after.upload_quota_exceeded(the project's daily upload bytes) is not retried. Hashing and uploads happen once per call, so a retried send sends the same references and uploads nothing again.File.binread), a list of byte values, or Base64 text. Base64 is decoded strictly, on one line or wrapped at 60, 64 or 76 characters with LF or CRLF; text in any other line layout, bare CR line ends included, is refused rather than read as Base64.ccandbcctake one address or a list, as before. The template form (template_id) refuses all three fields.Merge Danger
Warning
🟡 Merge risk: medium · two-way door until the gem release, one-way after it
attachmentsand the error codes become a contract on release.field_not_accepted.💚 How did you test it?
5659e3e, in a Ruby 3.2.2 container:bundle exec rspec2303 examples, 0 failures, 41 pending (the OpenTelemetry bundle, as in CONTRIBUTING);bundle exec rubocop156 files, no offenses;bundle exec rake public_api:checkpasses.5659e3e: all 30 checks pass.spec/posthog/emails_spec.rb,describe 'copies and attachments', drivesClient.new(...).emails.sendagainst an in-memory fake of PostHog's two routes and of file storage, wired in at the HTTP boundary. The fake PostHog checks the secret key, refuses a malformed manifest, signs each upload for its size and SHA-256, and counts the bytes of each upload it issues against the team's daily quota, skippingpresentfiles. The fake storage refuses a PUT that carriesAuthorizationor whose bytes don't match the signed size, checksum and digest, and meters the bytes it writes. The tests assert only what a caller sees: the result or error, the files in the bucket, the emails PostHog accepted, and both meters. Stubs remain only to inject faults: refused PUTs, storage redirecting a PUT elsewhere (307), storage'sRetry-Afterkept even when its answer breaks off, an unreachable storage, the quota refusal, an upload route answering 404 in JSON or HTML, and answers that don't name each file. A file storage with an http URL is the fake's own configuration. The size tests cover the spec's boundary of 29,182,875 bytes, each body by its UTF-8 bytes (57 ASCII characters count 78, 57 two-byte characters 156, a UTF-16 body as the UTF-8 it is sent as), the subject (42 ASCII bytes count 75, 43 count 150, 21 ×écount 75, an empty subject 0), HTML without text counted twice, an email without files left to PostHog, and eleven files refused before any file is read. Storage'sRetry-Afteris proven on a fake clock that the client's waits advance: storage stays busy until that time, so a client that retries early fails the send. Each test was mutation-checked: a wrong declared size,Authorizationon the PUT, dropped signed headers, a skipped or repeated PUT, no deduplication, no storage retry, a retried quota refusal, a dropped https rule, a followed redirect, an ignoredRetry-After, the HTML counted once, and a URL in the https error each fail at least one test.aa2546b, inline Base64) and pass on5659e3e; each review fix afterd94b541landed with a test that failed first.011bdd9(8e2bc63only skips the check for an email without files, and5659e3eadds a test; every case sends files) against a real SeaweedFS 4.29 bucket, the object storage PostHog's dev stack runs, with a stand-in for PostHog's two routes that presigns each PUT with its size and SHA-256 as signed headers, as the CDP Node store does. Every case callsclient.emails.send:Authorization(none of the run's 7 PUTs carried one), the send names both hashes, and the stored bytes hash to the same SHA-256.present, no PUT, sent.BadDigest, the client raisesattachment_upload_failedwith that answer, nothing is stored, and no send request is made.AccessDenied: Request has expired, no send.masterdoes:not_foundonattachmentswithEndpoint not found., one request, no PUT, no send.é, uploads and sends; 43 or 44 bytes are refused before any request.484811d(no request or response body changed since): each SDK's bodies through the attachment and address serializers of PostHogmasterand of feat(messaging): attach uploaded files to key sends by their hash posthog#131763 and #131664, on the Django and DRF versions the server pins. This PR's bodies pass the new routes. A mixed pair is refused at validation, never sent without its files:masterrefusessha256references and has no upload route, and the new server refuses the old inlinecontent. This PR's client againstmaster's own answer to the upload route (itsapi_not_foundview through drf-exceptions-hog 0.4.0, the version it pins) raisesnot_foundonattachmentsand makes no request after it.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code (Opus 5.5) reworked this PR from inline Base64 to hash, upload, send, test-first, with a GPT-6.1 Sol design pass before the push.
Retry-After; any other refusal stops the sendnot_foundonattachments, neverfield_not_accepted: the 404 also means sending from code is off for the projectnamedoes a refused upload get?service_unavailablefor a storage 5xx, otherwiseapplication_error, a storage 401 included; storage's status stays instatus_codeapp_hostfor local development; no new optiond94b541Involvement: one design pass; QA panels of nine lenses on
592d442, on4bc4f3fafter the subject joined the estimate, and on011bdd9over the full diff after derived text and https-only uploads, and on5659e3eafter the redirect test (fresh eyes on Opus 5.5; correctness, risk, design, anti-overengineering, systems, sweep and the author's and the SDK-spec maintainer's review perspectives on GPT-6.1 Sol), every valid finding fixed; fourteen final reads (GPT-6.1 Sol), the last clean on8e2bc63; a security bot's finding (uploads over plain http) fixed; StampHog declined by policy (public API, feature size), so a maintainer reviews; PostHog Review on every push, each finding fixed;fleet proof gatepasses on5659e3e.🤖 Generated with Claude Code