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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
- 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 <[email protected]>
posthog-ruby-sync Compliance ReportDate: 2026-11-04T09:18:18.925150+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 8ms |
| 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 | ✅ | 6ms |
| Format Validation.Custom Properties Preserved | ✅ | 6ms |
| Format Validation.Event Has Timestamp | ✅ | 7ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 6ms |
| Retry Behavior.Retries On 503 | ✅ | 5265ms |
| Retry Behavior.Does Not Retry On 400 | ❌ | 5ms |
| Retry Behavior.Does Not Retry On 401 | ❌ | 6ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8012ms |
| Retry Behavior.Implements Backoff | ❌ | 247ms |
| Retry Behavior.Retries On 500 | ✅ | 5156ms |
| Retry Behavior.Retries On 502 | ✅ | 5161ms |
| Retry Behavior.Retries On 504 | ✅ | 5117ms |
| Retry Behavior.Max Retries Respected | ❌ | 407ms |
| Deduplication.Generates Unique Uuids | ✅ | 14ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5110ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10285ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5118ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 14ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 8ms |
| Compression.Sends Gzip When Enabled | ✅ | 4ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 5ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 3ms |
| Batch Format.Multiple Events Batched Together | ❌ | 12ms |
| Error Handling.Does Not Retry On 403 | ❌ | 4ms |
| Error Handling.Does Not Retry On 413 | ❌ | 4ms |
| Error Handling.Retries On 408 | ✅ | 5113ms |
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 | ✅ | 8ms |
| 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 | ❌ | 5ms |
| 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 | ✅ | 5ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 136ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 142ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 8ms |
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-04T09:18:51.595251+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 9ms |
| Format Validation.Event Has Uuid | ✅ | 105ms |
| Format Validation.Event Has Lib Properties | ✅ | 107ms |
| Format Validation.Distinct Id Is String | ✅ | 106ms |
| Format Validation.Token Is Present | ✅ | 106ms |
| Format Validation.Custom Properties Preserved | ✅ | 107ms |
| Format Validation.Event Has Timestamp | ✅ | 105ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 7ms |
| Retry Behavior.Retries On 503 | ✅ | 5409ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2110ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2108ms |
| Retry Behavior.Respects Retry After Header | ✅ | 8114ms |
| Retry Behavior.Implements Backoff | ✅ | 15619ms |
| Retry Behavior.Retries On 500 | ✅ | 5212ms |
| Retry Behavior.Retries On 502 | ✅ | 5209ms |
| Retry Behavior.Retries On 504 | ✅ | 5212ms |
| Retry Behavior.Max Retries Respected | ✅ | 15618ms |
| Deduplication.Generates Unique Uuids | ✅ | 111ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5210ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10316ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5215ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 111ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 107ms |
| Compression.Sends Gzip When Enabled | ✅ | 106ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 106ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 4ms |
| Batch Format.Multiple Events Batched Together | ✅ | 111ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2108ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2107ms |
| Error Handling.Retries On 408 | ✅ | 5209ms |
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 107ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 107ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 106ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 106ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 106ms |
| Request Payload.Groups Round Trip | ✅ | 107ms |
| Request Payload.Groups Default To Empty Object | ✅ | 106ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 106ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 105ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 106ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 2ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 105ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 111ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 108ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 258ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 207ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 108ms |
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 <[email protected]>
|
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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
@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 |
💡 Motivation and Context
Ruby apps that send transactional email through PostHog can't copy anyone in or attach a file:
client.emails.sendfrom #324 takes one recipient and no files. An invoice, a receipt or a ticket export has to go out through another provider. posthog-node and posthog-python now takecc,bccandattachments(PostHog/posthog-js#5514, PostHog/posthog-python#1172), and the send route accepts them (PostHog/posthog#127988). This PR adds the same three fields to posthog-ruby.Stacked on #324; review that one first.
Important
Release this only after PostHog/posthog#127988 is deployed. Until then the send route refuses a send that has any of the three fields, and names the field.
What it does
ccandbcc: one address or a list, likereply_to. An empty list is left out of the request.attachments: a list of Hashes withfilename,content, and optionallycontent_typeandcontent_id. Acontent_idshows the file inline, referenced from the HTML ascid:<id>. Keys can be Symbols or Strings. An empty list is left out.File.binreadreturns, is the file's bytes, and the client encodes it as Base64;File.binread(path).bytes), is encoded the same way;Base64.encode64writes, and sent without them; other text with line breaks is refused, so a text file read withFile.readnever goes out garbled. Text that isn't ASCII, such as a PDF read withFile.read, can't be Base64 and is refused with a hint to useFile.binread.Encoding happens once, before the first attempt, so retries send the same body.
PostHog::EmailErrorcodeinvalid_attachment, namevalidation_error, and afieldsuch asattachments__0__content, as in Node and Python:path(the client reads no files);filenameorcontent, or text that isn't Base64 (including text that isn't ASCII).The message never holds the file's content.
PostHog::EmailErrorinvalid_requestwith the field and the server's message, why and fix: blocked file types, unsafe filenames or content IDs, more than 10 addresses per field or 10 files, and more than 512 KB decoded in total.template_id) refuses all three fields, as it doessubjectandhtml.Impact
Pays into the Workflows team's growth goal for Messaging (email is in scope) through transactional email sends from server SDKs. It removes a reason a Ruby app keeps a second email provider: invoices, receipts and exports need attachments. Expected move: small, up, once released. Run cost: n/a, same send route. Measure: sends with attachments or copies by SDK, not measured yet; it needs the same User-Agent attribution as #324.
Merge Danger
Low, two-way door until release. The change is additive: the three keywords are new and optional, and a send without them builds the same request as before. Releasing the gem makes the shape public, so the content rule (binary String, byte list, or Base64 text) should settle in review. Releasing before the server deploys would make sends with these fields fail with the server naming the field.
💚 How did you test it?
df3ff92:bundle exec rspec spec/posthog/emails_spec.rb71 examples, three runs in random order, 0 failures; the full suite, 2090 examples, 0 failures.bundle exec rubocop: 146 files, no offenses.bundle exec rake public_api:checkpasses.df3ff92: 20 checks green.spec/posthog/emails_spec.rb,describe 'copies and attachments', drivesClient.new(...).emails.sendagainst WebMock at the HTTP boundary: the wire body for every content form, Symbol and String keys, empty lists left out, one Base64 encoding shared by every retry, each client-side refusal with its field, and the template form refusing the fields.df3ff92through a local PostHog stack: Django at feat(messaging): send cc, bcc and attachments on a code send posthog#127988 (on #127123), cdp-api and the email and hogflow CDP workers at #127986 (on #127995 and #127985), the email flags on, and maildev as the SMTP server. A proxy between the Ruby client and Django records each request and response. Every case callsclient.emails.send:to, twocc, onebcc, a PDF fromFile.binreadand an inline PNG withcontent_id: 'logo':SendResultwith an id. maildev holds one message, with to and both cc in the headers and the bcc address only in the envelope (no Bcc header). Both files arrive byte for byte (sha256), and the PNG is inline withContent-ID: <logo>, which the HTML'scid:logouses.File.binread(path).bytes) and an already-encoded Base64 string: delivered, both files byte for byte.path:PostHog::EmailErrorinvalid_attachment, fieldattachments__0__path. No request reaches PostHog, and no mail arrives..exe: HTTP 400,EmailErrorinvalid_request, fieldattachments__0__filename, "Mailbox providers block.exefiles.", with the server's why and fix. No mail.EmailErrorinvalid_request, fieldattachments, "Send at most 512 KB of attachments in total, counted after decoding." No mail.cc,bccandattachmentslists: the request body has none of the three keys, and the email is delivered.df3ff92: bodies from feat(emails): send transactional email and read its status #324's SDK and from this PR go through the realSendEmailSerializerof the server before these fields (213fad6, the base of feat(messaging): send cc, bcc and attachments on a code send posthog#127988) and after (#127988). feat(emails): send transactional email and read its status #324's sends are accepted by the new server. This PR's plain sends and sends with empty lists are accepted by the old server. A send with cc, bcc or attachments is refused there, naming the field, rather than going out without it.code_send_fields.pyfrom #127988 on the Django and DRF versions the server pins. 15 of 15 checks pass.📝 Checklist
Emails#senddescribe the three keywords.If releasing new changes
pnpm changesetto generate a changeset file.posthog-rubyminor.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code (Claude Opus 5.5) as one slice of a map that brings every server SDK to parity on
emails.send. The shape follows posthog-node and posthog-python.invalid_attachmentPostHog::EmailErrorinvalid_attachmentwith afield, as Node and Python do, instead ofArgumentErrorInvolvement: no grilling rounds, no prototypes; QA swarm round 1 (nine lenses, including the team's
michaelandsdk-spec-conformanceperspectives) and round 2 on the fixes, with fixes ina3a1aabande29b8a7, then two clean final reads, the second ondf3ff92; live send, cross-version and contract proofs captured.🤖 Generated with Claude Code