Skip to content

feat(emails): send cc, bcc and attachments - #325

Open
Silthus wants to merge 36 commits into
mainfrom
feat/emails-attachments
Open

Silthus wants to merge 36 commits into
mainfrom
feat/emails-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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.send takes one recipient and no files. An invoice, a statement or a ticket PDF has to go out through another provider. This PR adds cc, bcc and attachments, 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).

posthog.emails.send(
  to: 'user@example.com', template_key: 'invoice', subject: 'Your invoice', html: rendered_html,
  cc: 'billing@example.com', bcc: ['audit@example.com'],
  attachments: [
    { filename: 'invoice.pdf', content: File.binread('invoice.pdf') },
    { filename: 'logo.png', content: File.binread('logo.png'), content_type: 'image/png', content_id: 'logo' }
  ]
)

Important

Release only after the merge gate above. Until then the send route refuses attachments with field_not_accepted, and a PostHog without the upload route answers not_found on attachments, 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_id
Loading
  • Checked before any request, and the file count before any file is read, as PostHog::EmailError with name validation_error, field attachments, and a why and fix:
    • more than 10 files: too_many_attachments;
    • an email with files over 40 MB after Base64 encoding: 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;
    • a file the client can't read, as before: invalid_attachment naming the field, such as attachments__0__path. An empty file is new here.
  • Upload: one request for the distinct files, with each file's SHA-256 and size. A file attached twice uploads once. A file the project uploaded before answers present and uploads nothing.
  • PUT: each upload file goes to its url with the returned headers and no Authorization. Storage checks the signed size and SHA-256, so corrupted bytes can't be stored.
  • A refused PUT sends no email. It raises attachment_upload_failed with storage's HTTP status and answer, such as File storage refused the upload of invoice.pdf with HTTP 400 (BadDigest: …), so no email was sent. Its name is service_unavailable for a storage 5xx and application_error otherwise, a storage 401 included, never one of PostHog's own categories such as missing_api_key. A PUT that never reaches storage raises network_error naming the file.
  • A PostHog without the upload route sends no email. The client reports PostHog's own answer, not_found on attachments with its reason (Endpoint not found.), and uploads nothing. A 404 without a JSON body, such as a proxy's HTML page, raises the same not_found on attachments. The same 404 comes from a project that can't send email from code, so the client doesn't guess between the two.
  • Files go to storage only over https. An upload URL without https stops the send before any file goes out, as api_error on the file's field, such as attachments__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_host set 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 as attachment_upload_failed, naming the file and never where storage pointed, and nothing is sent.
  • Retries: the upload request and the PUT retry network errors and 503s like the send does, and the PUT also retries storage's 500, 502 and 504, after storage's Retry-After when it sends one. A Retry-After over 30 seconds stops the retries and surfaces on the error as retry_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.
  • Content forms are unchanged: a binary String (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.
  • cc and bcc take 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

  • Public API: attachments and the error codes become a contract on release.
  • Worst case is a broken core action, never a silent one: every failure stops the send before PostHog queues it.
  • Switch-off: the CDP Node attachment allowlist. With it empty, the upload route and the send route refuse files synchronously with field_not_accepted.

💚 How did you test it?

  • Lane gate on 5659e3e, in a Ruby 3.2.2 container: bundle exec rspec 2303 examples, 0 failures, 41 pending (the OpenTelemetry bundle, as in CONTRIBUTING); bundle exec rubocop 156 files, no offenses; bundle exec rake public_api:check passes.
  • CI on 5659e3e: all 30 checks pass.
  • Test seam: spec/posthog/emails_spec.rb, describe 'copies and attachments', drives Client.new(...).emails.send against 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, skipping present files. The fake storage refuses a PUT that carries Authorization or 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's Retry-After kept 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's Retry-After is 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, Authorization on 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 ignored Retry-After, the HTML counted once, and a URL in the https error each fail at least one test.
  • Red, then green: the new tests fail on the previous head of this branch (aa2546b, inline Base64) and pass on 5659e3e; each review fix after d94b541 landed with a test that failed first.
  • Live run on 011bdd9 (8e2bc63 only skips the check for an email without files, and 5659e3e adds 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 calls client.emails.send:
    1. Two new files, 2 MB and 4 KB: both PUT to the bucket without 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.
    2. The same files again: both present, no PUT, sent.
    3. A URL signed for other bytes: storage answers 400 BadDigest, the client raises attachment_upload_failed with that answer, nothing is stored, and no send request is made.
    4. An expired URL: storage answers 403 AccessDenied: Request has expired, no send.
    5. Eleven files, and one file of 29,182,876 bytes: refused before any request.
    6. One file of 29,182,875 bytes, the largest that fits: uploaded whole and sent.
    7. A file of 29,182,761 bytes with 57 characters of text: 58 characters of HTML are refused before any request; 57 upload and send.
    8. The stand-in without its upload route, answering as PostHog master does: not_found on attachments with Endpoint not found., one request, no PUT, no send.
    9. A file of 29,182,821 bytes: a 42-byte subject, ASCII or 21 × é, uploads and sends; 43 or 44 bytes are refused before any request.
    10. 57 characters of HTML and no text: refused beside a file of 29,182,818 bytes, before any request, because they count twice; sent beside 29,182,761.
  • Cross-version on 484811d (no request or response body changed since): each SDK's bodies through the attachment and address serializers of PostHog master and 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: master refuses sha256 references and has no upload route, and the new server refuses the old inline content. This PR's client against master's own answer to the upload route (its api_not_found view through drf-exceptions-hog 0.4.0, the version it pins) raises not_found on attachments and makes no request after it.
  • Not run: an end-to-end send through a real PostHog. The server side is not live yet; the opening stack of PostHog/michaelr-context#83 proves it on dev once it is.
  • The published emails spec in PostHog/sdk-specs doesn't cover the code form or attachments yet, so the contract is the server PRs above.

📝 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 changeset to 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.

Question Answer Who Where
How do files reach PostHog? Hash, upload straight to storage, then send references, replacing inline Base64 👤 Human PostHog/michaelr-context#83
Which content forms stay? Binary String, byte list and Base64 text, as reviewed before; Base64 is decoded strictly, on one line or wrapped at 60, 64 or 76 characters 🤖 Agent comment
What counts towards the 40 MB estimate? Each file and each body's UTF-8 bytes at their Base64 size with line breaks, the HTML twice when there is no text, the subject at 75 bytes per started 42 of its UTF-8 bytes, plus 64 KiB, as spec #71 and PostHog/posthog#131944 count; only when the email has files 🤖 Agent comment
Does the PUT retry? Network errors and 503s, like the send, and storage's 500, 502 and 504, after storage's Retry-After; any other refusal stops the send 🤖 Agent comment
Does a file attached twice upload twice? No; it uploads once and the send keeps both references 🤖 Agent comment
What does an upload-route 404 raise? PostHog's own not_found on attachments, never field_not_accepted: the 404 also means sending from code is off for the project 🤖 Agent comment
What name does a refused upload get? service_unavailable for a storage 5xx, otherwise application_error, a storage 401 included; storage's status stays in status_code 🤖 Agent comment
May files go to storage without TLS? No: an http upload URL stops the send before any file goes out, naming the file and never the URL. The only exception is the existing plain-HTTP opt-in, an http app_host for local development; no new option 🤖 Agent thread
Check the size with a running budget while reading files? No; count the files first, then read and check the total once, as review suggested in part 🤖 Agent review thread on d94b541

Involvement: one design pass; QA panels of nine lenses on 592d442, on 4bc4f3f after the subject joined the estimate, and on 011bdd9 over the full diff after derived text and https-only uploads, and on 5659e3e after 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 on 8e2bc63; 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 gate passes on 5659e3e.

🤖 Generated with Claude Code

Silthus and others added 6 commits November 2, 2026 15:47
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>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-10T08:48:35.851415+00:00
Duration: 55898ms

⚠️ Some Tests Failed

39/47 tests passed, 8 failed


Capture Tests

⚠️ 23/30 tests passed, 7 failed

View Details
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

⚠️ 16/17 tests passed, 1 failed

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']

@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 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.

Comment thread lib/posthog/emails.rb Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-10T08:49:14.915528+00:00
Duration: 98480ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

✅ 30/30 tests passed

View Details
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

⚠️ 16/17 tests passed, 1 failed

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']

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

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 How does Ruby tell file bytes from Base64 text? Ruby has one String type for both, so the encoding decides. A binary String (File.binread) or a list of byte values (Resend's Ruby style, File.binread(path).bytes) is the file's bytes, and the client encodes it. Any other String must already be Base64; text that isn't raises invalid_attachment naming the field. The alternative, a separate content_base64: key, would differ from Node, Python and the server's field names.
  • 🤖 What does an attachment the client can't send raise? PostHog::EmailError with code invalid_attachment (name validation_error) and a field such as attachments__0__content, as Node and Python do, instead of the ArgumentError other bad arguments raise. The caller handles a refused file the same way whether the client or PostHog refused it.
  • 🤖 Is a contract test enough in place of the live send? No longer needed. The live send ran through a local PostHog stack (Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988, the CDP chain to #127986, maildev) at df3ff92 and passed every case. The contract test stays as extra evidence.

Silthus and others added 4 commits November 4, 2026 07:23
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>
@Silthus
Silthus marked this pull request as ready for review November 4, 2026 09:48
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 09:48
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 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 df3ff92: 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.

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
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

SDK performance: posthog-ruby

Benchmarks 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
Test Measurement Before PR With PR Change Assessment
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Queue an event (32-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (32-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (4,096-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Queue an event (4,096-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​; head:​ Background serialization has no public enqueue completion boundary; use completed-delivery workloads for process-wide allocation counts.​
Evaluate all 10 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate all 100 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate all 1,000 flags
Shard: flags-scaling
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate one boolean flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a cohort-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (early match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (late match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a flag (no match)
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a property-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a regex-filtered flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a rollout flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Evaluate a multivariate flag
Shard: flags-single
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​; head:​ Ruby has no cumulative allocated-byte counter here; retained ObjectSpace bytes are not allocation volume.​
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocations per operation 56.27 count/op 56.27 count/op No observed change Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 111.36 µs/op 111.45 µs/op <0.1% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 28.35 MiB 28.41 MiB 0.2% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 31.11 MiB 31.44 MiB 1.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling 5.28 MiB 5.29 MiB 0.3% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 30.35 MiB 30.66 MiB 1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 25.41 µs/op 25.61 µs/op 0.8% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 30.97 MiB 31.28 MiB 1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocations per operation 58.65 count/op 58.65 count/op No observed change Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 111.43 µs/op 111.5 µs/op <0.1% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 33.18 MiB 33.26 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 42.38 MiB 42.41 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling 9.15 MiB 9.16 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 40.67 MiB 40.69 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 36.82 µs/op 36.57 µs/op 0.7% less time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 42.19 MiB 42.27 MiB 0.2% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocations per operation 58.68 count/op 58.68 count/op No observed change Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 1.02 ms/op 1.01 ms/op <0.1% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 28.28 MiB 28.35 MiB 0.3% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 30.35 MiB 30.49 MiB 0.5% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling 5.28 MiB 5.29 MiB 0.3% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 30.35 MiB 30.49 MiB 0.5% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 36.61 µs/op 35.93 µs/op 1.9% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 30.35 MiB 30.49 MiB 0.5% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocations per operation 58.68 count/op 58.68 count/op No observed change Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 1.01 ms/op 1.01 ms/op <0.1% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 33.15 MiB 33.32 MiB 0.5% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.53 MiB 41.01 MiB 1.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling 9.15 MiB 9.16 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 40.21 MiB 40.29 MiB 0.2% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 43.69 µs/op 42.88 µs/op 1.9% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.78 MiB 41.14 MiB 0.9% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 11.18 µs/op 11.27 µs/op 0.8% more time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 11.17 µs/op 11.27 µs/op 0.8% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 11.18 µs/op 11.24 µs/op 0.6% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 11.17 µs/op 11.24 µs/op 0.6% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 343.02 count/op 343.02 count/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 164.25 µs/op 162.64 µs/op 1% less time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 164.24 µs/op 162.63 µs/op 1% less time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocations per operation 3,324 count/op 3,324 count/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Time per operation 1.5 ms/op 1.51 ms/op 0.5% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 1.5 ms/op 1.51 ms/op 0.4% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 33,113.59 count/op 33,113.59 count/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 15.33 ms/op 15.43 ms/op 0.7% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 15.33 ms/op 15.43 ms/op 0.7% more time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 38.78 MiB 38.81 MiB <0.1% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 40.44 MiB 40.27 MiB 0.4% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Managed heap after settling 7.97 MiB 7.99 MiB 0.2% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 39.39 MiB 39.18 MiB 0.5% less memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 40.14 MiB 39.9 MiB 0.6% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 37.64 MiB 38.02 MiB 1% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 41.4 MiB 38.99 MiB 5.8% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling 9.48 MiB 9.5 MiB 0.2% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 41.36 MiB 38.99 MiB 5.7% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 41.37 MiB 38.99 MiB 5.8% less memory Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocations per operation 38 count/op 38 count/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Time per operation 10.51 µs/op 10.42 µs/op 0.9% less time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 10.51 µs/op 10.42 µs/op 0.9% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocations per operation 38 count/op 38 count/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Time per operation 14.34 µs/op 14.38 µs/op 0.2% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 14.34 µs/op 14.38 µs/op 0.2% more time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocations per operation 55 count/op 55 count/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Time per operation 18.16 µs/op 18.14 µs/op 0.1% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 18.16 µs/op 18.14 µs/op 0.1% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocations per operation 76 count/op 76 count/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Time per operation 25.6 µs/op 25.57 µs/op 0.2% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 25.6 µs/op 25.56 µs/op 0.2% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocations per operation 67 count/op 67 count/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Time per operation 22.83 µs/op 22.72 µs/op 0.5% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 22.83 µs/op 22.72 µs/op 0.5% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocations per operation 34 count/op 34 count/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Time per operation 10.86 µs/op 10.77 µs/op 0.8% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 10.86 µs/op 10.77 µs/op 0.8% less time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 99 count/op 99 count/op <0.1% fewer Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 31.36 µs/op 31.37 µs/op <0.1% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 31.35 µs/op 31.37 µs/op <0.1% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocations per operation 31 count/op 31 count/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Time per operation 9.39 µs/op 9.43 µs/op 0.4% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 9.39 µs/op 9.43 µs/op 0.4% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocations per operation 37 count/op 37 count/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Time per operation 11.68 µs/op 11.8 µs/op 1% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 11.68 µs/op 11.8 µs/op 1% more time Not flagged

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 97102efc5ca6edc42e026e8bdddfb86c66fce3fa → 5659e3eb45ceb71f6d1f79ac87fd63d83e5952ed.

Full report and diagnostics

102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in comparison.json, including per-runner environments, resource limits, measurement methods, samples and checkpoints.

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

Silthus and others added 2 commits November 10, 2026 06:27
…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>
@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 ab93bd5: no findings.

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

…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>
@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 8e2b20a: no findings.

… 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>
@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 011bdd9: no findings.

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>
@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 8e2bc63: 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.

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>
@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 5659e3e: 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.

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 marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The code looks good; no qualifying findings. Verdict: correct.

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.

3 participants