Skip to content

feat(emails): send transactional email and read its status - #324

Merged
Silthus merged 10 commits into
mainfrom
feat/emails-send
Nov 5, 2026
Merged

Silthus merged 10 commits into
mainfrom
feat/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Ruby apps can't send transactional email through PostHog today. posthog-node and posthog-python already can, with posthog.emails.send. This PR adds the same API to posthog-ruby: client.emails.send queues one email, and client.emails.get reads what happened to it.

posthog = PostHog::Client.new(api_key: 'phc_...', secret_key: 'phs_...', host: 'https://eu.i.posthog.com')

# A template made in PostHog
posthog.emails.send(to: '[email protected]', template_id: '<template UUID>', template_data: { name: 'Ada' },
                    distinct_id: 'user_123', idempotency_key: 'welcome:user_123')

# Content your code rendered, such as React Email output rendered to HTML in your app
result = posthog.emails.send(to: '[email protected]', template_key: 'password-reset', subject: 'Reset your password',
                             html: rendered_html, distinct_id: 'user_123')
posthog.emails.get(result.id).status # => "delivered"

It follows the cross-SDK contract in PostHog/posthog#120921, and matches posthog-node's packages/node/src/emails/ with PostHog/posthog-js#5509 and posthog-python's posthog/emails.py with PostHog/posthog-python#1171.

Important

Don't release the code form (template_key) before the server's code-form send route is deployed: until then a code send answers 400. The template form (template_id) and get work against PostHog today.

What it does

  • Two forms, sent as a flat JSON body to POST /api/projects/@current/messaging/send/email/ on the app host:
    • template: template_id (lowercased) and template_data (sent as message_data);
    • code: template_key, subject, html and/or text, plus from, reply_to (a string becomes a list) and capture_engagement (always sent, true by default).
  • Both forms take to, distinct_id, idempotency_key and max_retries. A send without an idempotency key gets a UUID v4, and every attempt reuses it.
  • Status read: GET /api/projects/@current/messaging/emails/{message_id}/ returns an Emails::Status with Time values for created_at and status_at.
  • Auth and host: the client's secret_key, sent as Authorization: Bearer. A PostHog Cloud host maps to its region's app host. Any other host needs the new app_host option, because a reverse proxy for ingestion must not receive the secret key (missing_app_host).
  • Retries: network errors, timeouts, a reply whose body a closed connection cut off or whose compressed body is corrupt, 409 idempotency_key_in_flight, 429 other than quota_limited, and 503. The waits are about 1 s and then 2 s with jitter, or Retry-After when that's longer; a reply whose body broke off still counts its Retry-After. A Retry-After over 30 s raises at once.
  • Errors: a send PostHog didn't queue raises PostHog::EmailError. It carries code, name (the contract's Resend-style category), status_code, why, fix, docs_url, field and retry_after in seconds. Bad arguments raise ArgumentError before any request, as Ruby does for an unknown keyword.
  • Lifecycle: a disabled client (no api_key) raises client_disabled. After shutdown, sends raise client_closed. shutdown(timeout:) waits for sends in flight, retries included, within its timeout. A forked child doesn't wait for its parent's sends.
  • New client options: app_host, email_request_timeout_seconds (15), email_request_max_retries (2) and migrated_from (sent as X-PostHog-Migrated-From). nil means the default. app_host must be an http or https URL. A bad value raises ArgumentError from Client.new, before the client starts its worker or polls flags.
  • skip_ssl_verification: the existing option also turns off certificate checks for email requests.
  • Left out, as the contract's current scope says: local templates, template_data on the code form, cc, bcc, headers, tags and attachments, and any React or rendering dependency. The app renders the email and passes html.

Transport#parse_retry_after moved to Utils so both share it. It also ignores a non-finite value now.

Divergence from sdk-specs

The emails spec in PostHog/sdk-specs covers the template form and get, and says the code form isn't covered yet. This PR adds the code form as posthog-node and posthog-python ship it. Everything the spec covers follows it, including the Shutdown requirement.

Impact

Pays into the Workflows team's growth goal for Messaging (email is in scope) through transactional email sends from server SDKs. Ruby is the next server SDK after Node and Python. Expected move: small to medium, up, once released. Run cost: n/a, it uses the same send route as the other SDKs. Measure: sends by SDK, not measured yet. The server would need to attribute posthog-ruby/ User-Agents the way it plans to for the other SDKs.

Merge Danger

Low, two-way door until release. The change is additive and opt-in: nothing runs unless the app calls emails. The only existing path it touches is Client#shutdown, which now also waits for email requests in flight; with none in flight, that wait returns at once. Releasing the gem makes the API public, so its shape should settle in review. Releasing the code form before the server deploys it would give callers a 400.

💚 How did you test it?

  • Lane gate on 21c81ad: bundle exec rspec spec/posthog/emails_spec.rb 63 examples, 0 failures, three runs in random order. bundle exec rubocop: 146 files, no offenses. bundle exec rake public_api:check passes. The full suite last ran locally at 90c1afb (2080 examples, 0 failures); after that the box was below its disk floor for heavy runs, so CI runs it.
  • Test seam: spec/posthog/emails_spec.rb drives the public API, Client.new(...).emails.send/get, against WebMock at the HTTP boundary. 63 examples cover both forms' wire bodies and headers, app host mapping and its URL check, the custom host refusal, the missing secret key, the disabled and closed client, argument and option checks (including nil options and a bad option raising before any flags request), the retry table, Retry-After, the error names, warnings (read from the logger's output), status parsing, shutdown waiting within its timeout during a retry backoff, and a forked child that doesn't wait for the parent's send.
  • Real sockets in the specs: a local server, with WebMock off, sends a 202 whose body breaks off (retried), a 202 with a corrupt gzip body (retried), a 409 whose body breaks off (retried), a 429 with Retry-After: 60 whose body breaks off (raises with retry_after 60, no retry), a 503 with Retry-After: 3 whose body breaks off (waits 3 s, then succeeds), and a TLS server with a self-signed certificate (succeeds with skip_ssl_verification).
  • CI on 21c81ad: 25 of 25 checks green (23 passed, 2 neutral compliance checks), including RSpec on Ruby 3.2, 3.3, 3.4 and latest Rails, and the gem build.
  • Real sockets, local stand-in: the client sent through Net::HTTP to a local server that answers like the send route. Results:
    • a 503 with Retry-After: 1 was retried once with the same idempotency key, after about 1.2 s;
    • 429 quota_limited raised monthly_quota_exceeded without a retry;
    • get sent the ID lowercased and parsed the record;
    • after shutdown, a send raised client_closed;
    • a proxy host without app_host raised missing_app_host and sent nothing.
  • Real PostHog (us.posthog.com, invalid key): both forms and get raised authentication_failed / missing_api_key with status 401 and the server's message.
  • Proof (fleet proof gate on 21c81ad: passes, judge included):
    • Live run: the stand-in run and the real PostHog run above, rerun at e94874c with the same results, scripts and raw output saved. c843d53 and 21c81ad change only the spec file.
    • Shadow diff of the paths this PR shares with existing code: Client#shutdown and the batch transport's Retry-After parsing ran with the base's code and with HEAD's over the same inputs (12 Retry-After values, a shutdown after three captures, a second shutdown, a shutdown after a 429 with Retry-After: 1, a test_mode shutdown and a disabled client). The one difference is intended: a Retry-After of 1e400 was Infinity on the base and is ignored on HEAD.
    • Review: GPT-6.1 Sol round 1 found one high issue. A child forked while another thread sent email inherited a stuck in-flight count, so its shutdown waited out its timeout. Fixed in 16a2c62 with a fork test that failed before the fix. The final read found nothing.
    • QA swarm round 3, a panel of nine lenses (Fresh eyes on Claude Opus 5.5; correctness, risk, design, anti-overengineering, systems, sweep and the team's michael and sdk-spec-conformance perspectives on GPT-6.1 Sol): seven findings fixed in 90c1afb with tests that failed first. A nil email option failed Client.new after the flags poller had started; an app_host without a scheme sent to localhost; a corrupt gzip body escaped as Zlib::DataError; skip_ssl_verification didn't reach email; a refusal whose body broke off lost its Retry-After; and two specs leaned on timing and a logger mock. Three were rejected: no shadow diff is owed for a new capability, the flags poller's use of host is unchanged from main, and the comments record security reasons.
    • QA swarm round 4, on the fix diff: Fresh eyes, correctness and systems each found that a 409 whose body broke off no longer retried. Fixed in e94874c: a reply whose body broke off is a retryable network error that keeps its Retry-After. Final reads on e94874c and on the spec fixes after it found nothing. The proof judge then found that a shutdown spec released its retry on a timer and could pass without shutdown waiting; c843d53 makes it fail when shutdown doesn't wait, and 21c81ad releases held threads even when an assertion fails.
    • PostHog Review: found the same fork issue, and a 202 whose body was cut off by a closed connection, which Net::HTTP returned as a whole body, so the send raised a non-retryable api_error. Fixed in e33feda with ignore_eof: false and a real-socket spec that failed before the fix. GPT-6.1 Sol's round 2 on the fixes found nothing.
    • Gap: no successful send or status read against real PostHog. That needs a minted project secret key and real email from a production project, and the code form can't succeed until the server deploys it. It stays owed before release.

📝 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. The README points to posthog.com docs; the YARD docs on Client#emails, Emails#send and Emails#get describe the API. The posthog.com Ruby page is a follow-up.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file. posthog-ruby minor.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code (Claude Opus 5.5) as one slice of a map that ports emails.send to every server SDK. The design ports posthog-node and posthog-python; it doesn't invent new behavior. The review was run by GPT-6.1 Sol through fleet proof review.

Question Answer Who Where
Which forms does the SDK send? The template form and rendered content only; local templates, cc, bcc, headers, tags and attachments wait 👤 Human Silthus/posthog#461
Does the SDK render React email? No: the app pulls in its renderer and passes html; the SDK adds no dependency 👤 Human Silthus/posthog#461
What is the method called? emails.send, overriding Object#send on PostHog::Emails only, as Resend's Ruby SDK and the other PostHog SDKs name it; reflection on that object uses public_send or __send__ 🤖 Agent comment
How do bad arguments fail? ArgumentError, before any request, like posthog-python's native exceptions; request and SDK failures raise PostHog::EmailError 🤖 Agent comment
Does test_mode stop email sends? No: test_mode queues events only, and a disabled client (no api_key) raises client_disabled, as in the other SDKs 🤖 Agent comment
Does posthog-rails get setters for the new options? No: PostHog.init(app_host: ...) passes them through, and the init block already lists only some core options 🤖 Agent comment
Does a forked child wait for its parent's sends on shutdown? No: the child resets the in-flight count, as SendWorker does after a fork 🤖 Agent comment
Is a successful send against real PostHog owed before review? No: the SDK's success path ran over real sockets against a stand-in, and real PostHog answered every call; a prod send needs a minted key and real email, and the code form waits for the server deploy 🤖 Agent comment

Involvement: no grilling rounds, no prototypes; four review rounds (two single-reviewer rounds by GPT-6.1 Sol, then two QA swarm panels with the team's perspectives) and final reads, plus PostHog Review; proof captured.

🤖 Generated with Claude Code

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]>
@Silthus Silthus self-assigned this Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-11-05T11:01:55.811389+00:00
Duration: 55969ms

⚠️ 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 ✅ 7ms
Format Validation.Event Has Lib Properties ✅ 6ms
Format Validation.Distinct Id Is String ✅ 10ms
Format Validation.Token Is Present ✅ 7ms
Format Validation.Custom Properties Preserved ✅ 8ms
Format Validation.Event Has Timestamp ✅ 8ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 7ms
Retry Behavior.Retries On 503 ✅ 5239ms
Retry Behavior.Does Not Retry On 400 ❌ 8ms
Retry Behavior.Does Not Retry On 401 ❌ 6ms
Retry Behavior.Respects Retry After Header ✅ 8017ms
Retry Behavior.Implements Backoff ❌ 309ms
Retry Behavior.Retries On 500 ✅ 5160ms
Retry Behavior.Retries On 502 ✅ 5120ms
Retry Behavior.Retries On 504 ✅ 5119ms
Retry Behavior.Max Retries Respected ❌ 530ms
Deduplication.Generates Unique Uuids ✅ 18ms
Deduplication.Preserves Uuid On Retry ✅ 5114ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10251ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5162ms
Deduplication.No Duplicate Events In Batch ✅ 19ms
Deduplication.Different Events Have Different Uuids ✅ 9ms
Compression.Sends Gzip When Enabled ✅ 7ms
Batch Format.Uses Proper Batch Structure ✅ 6ms
Batch Format.Flush With No Events Sends Nothing ✅ 3ms
Batch Format.Multiple Events Batched Together ❌ 17ms
Error Handling.Does Not Retry On 403 ❌ 5ms
Error Handling.Does Not Retry On 413 ❌ 5ms
Error Handling.Retries On 408 ✅ 5110ms

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 ✅ 8ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 8ms
Request Payload.Flags Request Omits Authorization Header ✅ 8ms
Request Payload.Token In Flags Body Matches Init ✅ 7ms
Request Payload.Groups Round Trip ✅ 6ms
Request Payload.Groups Default To Empty Object ✅ 7ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 6ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 7ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 6ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 7ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 9ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 8ms
Retry Behavior.Retries Flags On 502 ✅ 109ms
Retry Behavior.Retries Flags On 504 ✅ 150ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 10ms

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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-11-05T11:02:41.522075+00:00
Duration: 98502ms

⚠️ 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 ✅ 108ms
Format Validation.Event Has Uuid ✅ 105ms
Format Validation.Event Has Lib Properties ✅ 120ms
Format Validation.Distinct Id Is String ✅ 106ms
Format Validation.Token Is Present ✅ 106ms
Format Validation.Custom Properties Preserved ✅ 106ms
Format Validation.Event Has Timestamp ✅ 105ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 7ms
Retry Behavior.Retries On 503 ✅ 5410ms
Retry Behavior.Does Not Retry On 400 ✅ 2107ms
Retry Behavior.Does Not Retry On 401 ✅ 2108ms
Retry Behavior.Respects Retry After Header ✅ 8115ms
Retry Behavior.Implements Backoff ✅ 15720ms
Retry Behavior.Retries On 500 ✅ 5211ms
Retry Behavior.Retries On 502 ✅ 5210ms
Retry Behavior.Retries On 504 ✅ 5211ms
Retry Behavior.Max Retries Respected ✅ 15622ms
Deduplication.Generates Unique Uuids ✅ 108ms
Deduplication.Preserves Uuid On Retry ✅ 5210ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10416ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5212ms
Deduplication.No Duplicate Events In Batch ✅ 109ms
Deduplication.Different Events Have Different Uuids ✅ 105ms
Compression.Sends Gzip When Enabled ✅ 104ms
Batch Format.Uses Proper Batch Structure ✅ 104ms
Batch Format.Flush With No Events Sends Nothing ✅ 3ms
Batch Format.Multiple Events Batched Together ✅ 108ms
Error Handling.Does Not Retry On 403 ✅ 2108ms
Error Handling.Does Not Retry On 413 ✅ 2108ms
Error Handling.Retries On 408 ✅ 5211ms

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 106ms
Request Payload.Flags Request Uses V2 Query Param ✅ 105ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 105ms
Request Payload.Flags Request Omits Authorization Header ✅ 105ms
Request Payload.Token In Flags Body Matches Init ✅ 106ms
Request Payload.Groups Round Trip ✅ 105ms
Request Payload.Groups Default To Empty Object ✅ 105ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 105ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 105ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 105ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 104ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 111ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 107ms
Retry Behavior.Retries Flags On 502 ✅ 210ms
Retry Behavior.Retries Flags On 504 ✅ 247ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 107ms

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 156067d: 2 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
Comment thread lib/posthog/emails.rb Resolved
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]>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions made while building this PR:

  • 🤖 What is the method called? emails.send, as Resend's Ruby SDK and the other PostHog SDKs name it. It overrides Object#send on PostHog::Emails only, so reflection on that object uses public_send or __send__.
  • 🤖 How do bad arguments fail? ArgumentError, before any request, as posthog-python keeps native exceptions for arguments. Request and SDK failures raise PostHog::EmailError.
  • 🤖 Does test_mode stop email sends? No. test_mode keeps events queued. A disabled client (no api_key) raises client_disabled, as in the other SDKs.
  • 🤖 Does posthog-rails get setters for the new options? No. PostHog.init(app_host: ...) passes them through, and the init block already lists only some core options.
  • 🤖 Does a forked child wait for its parent's sends on shutdown? No. The child resets the in-flight count, as SendWorker does after a fork (review round 1).
  • 🤖 Is a successful send against real PostHog owed before review? No. The SDK's success path ran over real sockets against a stand-in, and real PostHog answered every call. A successful prod send needs a minted secret key and real email, and the code form waits for the server deploy.

@Silthus
Silthus marked this pull request as ready for review November 2, 2026 16:00
@Silthus
Silthus requested a review from a team as a code owner November 2, 2026 16:00
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]>

@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: two policy gates refused it. The deny-list gate matched public_api, because the change adds new public surface, including Client#emails, the PostHog::Defaults::Emails constants and an updated public_api_snapshot.txt. The tier gate classified it as T2-never (1198 lines across 9 files, cross-cutting, a new feature), a tier that is never auto-approved.

Please ask a human reviewer to look at this pull request. Splitting it up won't clear the public API match, since the new emails interface is the change itself.

Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: public_api
size ✓ 612L, 6F substantive, 1198L/9F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (1198L, 9F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head e33feda

@stamphog stamphog Bot removed the stamphog label Nov 2, 2026
- 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 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 90c1afb: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread lib/posthog/client.rb
# Built before the worker, the instance count and the flags poller start, so a bad email option raises
# before anything runs or sends the secret key.
@emails = Emails.new(
secret_key: secret_key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Email credentials still reach the ingestion proxy

With host: 'https://proxy.example.com' and app_host: 'https://us.posthog.com', this email credential is also passed to FeatureFlagsPoller at client.rb:236–240, whose constructor fetches /flags/definitions with Authorization: Bearer <secret_key> by default (feature_flags.rb:115–121, 1854–1858), exposing the key to the proxy before any email call. Keep email-only credentials out of the ingestion-host poller, or route its secret-bearing requests through the trusted app host.

@Silthus Silthus mentioned this pull request Nov 4, 2026
6 tasks done
@veria-ai

veria-ai Bot commented Nov 4, 2026

Copy link
Copy Markdown

PR overview

The PR adds support for sending transactional emails and reading their status through the PostHog client.

One issue remains open: the email credential is also used by the feature-flag poller, which sends it to the configured ingestion proxy rather than the trusted app host. This can expose the credential to the proxy before any email request is made, though the exposure depends on the client's host configuration. No issues have been addressed yet.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

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 <[email protected]>
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]>
Silthus and others added 2 commits November 4, 2026 07:32
The shutdown backoff specs released and joined their threads only after
their assertions passed, so a failing assertion left the sender blocked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Nov 5, 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 <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 113.93 µs/op 114.15 µs/op 0.2% more time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 28.18 MiB 28.3 MiB 0.4% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 31.14 MiB 31.12 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling 5.25 MiB 5.27 MiB 0.4% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 30.73 MiB 30.36 MiB 1.2% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 30.41 µs/op 30.24 µs/op 0.5% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 31.08 MiB 30.88 MiB 0.6% less 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 <0.1% fewer Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 114.1 µs/op 114.4 µs/op 0.3% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 33.16 MiB 33.11 MiB 0.2% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 41.65 MiB 41.82 MiB 0.4% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling 9.12 MiB 9.14 MiB 0.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 38.38 MiB 39.65 MiB 3.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 42.7 µs/op 42.78 µs/op 0.2% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 41.45 MiB 41.63 MiB 0.4% 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 <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 1.02 ms/op 1.02 ms/op <0.1% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 28.15 MiB 28.24 MiB 0.3% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 30.22 MiB 30.41 MiB 0.6% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling 5.25 MiB 5.27 MiB 0.4% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 30.22 MiB 30.41 MiB 0.6% more memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 41.32 µs/op 41.1 µs/op 0.5% less time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 30.22 MiB 30.41 MiB 0.6% 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 <0.1% fewer Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 1.02 ms/op 1.02 ms/op <0.1% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 32.89 MiB 33.15 MiB 0.8% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 41.22 MiB 40.41 MiB 2% less memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling 9.12 MiB 9.14 MiB 0.3% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 39.45 MiB 40.12 MiB 1.7% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 49.46 µs/op 49.74 µs/op 0.6% more time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 41.04 MiB 40.77 MiB 0.6% less memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 13.85 µs/op 13.71 µs/op 1.1% less time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 13.87 µs/op 13.71 µs/op 1.2% less time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 13.65 µs/op 13.68 µs/op 0.3% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 13.64 µs/op 13.68 µs/op 0.3% 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 154.37 µs/op 154.49 µs/op <0.1% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 154.36 µs/op 154.45 µs/op <0.1% more 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.47 ms/op 1.47 ms/op 0.3% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 1.47 ms/op 1.47 ms/op 0.3% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 33,113.5 count/op 33,113.56 count/op <0.1% more Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 14.67 ms/op 14.69 ms/op 0.2% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 14.67 ms/op 14.69 ms/op 0.2% more time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 38.58 MiB 39.29 MiB 1.8% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 40.37 MiB 40.87 MiB 1.2% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Managed heap after settling 7.94 MiB 7.97 MiB 0.3% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 38.93 MiB 39.49 MiB 1.4% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 40.08 MiB 40.63 MiB 1.4% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 39.38 MiB 37.87 MiB 3.8% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 40.96 MiB 41.32 MiB 0.9% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling 9.45 MiB 9.48 MiB 0.3% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 39.23 MiB 41.06 MiB 4.7% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 40.74 MiB 41.16 MiB 1% more 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 9.08 µs/op 9.14 µs/op 0.6% more time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 9.08 µs/op 9.14 µs/op 0.6% more 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 12.3 µs/op 12.37 µs/op 0.5% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 12.3 µs/op 12.37 µs/op 0.5% 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 15.63 µs/op 15.57 µs/op 0.4% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 15.63 µs/op 15.57 µs/op 0.4% 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 21.94 µs/op 22.03 µs/op 0.4% more time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 21.94 µs/op 22.03 µs/op 0.4% more 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 19.71 µs/op 19.67 µs/op 0.2% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 19.71 µs/op 19.67 µs/op 0.2% 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 9.21 µs/op 9.28 µs/op 0.8% more time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 9.21 µs/op 9.28 µs/op 0.8% more 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 27 µs/op 27.04 µs/op 0.2% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 27 µs/op 27.04 µs/op 0.2% 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 7.95 µs/op 8.21 µs/op 3.3% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 7.95 µs/op 8.21 µs/op 3.3% 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 9.9 µs/op 10.24 µs/op 3.4% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 9.9 µs/op 10.24 µs/op 3.4% 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 cf92bbf3d721995e8ecb1c2cf13671e891910641 → e1ee0d405ce2ebf07fe4b0164743ad8bd5be8bd6.

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.

@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 matched public_api: the change adds Client#emails and new PostHog::Defaults::Emails constants, which shows up in public_api_snapshot.txt. The tier gate classified it as T2-never (1346 lines across 9 files, cross-cutting, a new feature), so it needs a human reviewer regardless of the size check passing.

Please ask a maintainer to review it. If you'd like to reduce what a reviewer has to take in, you could split it, for example by moving the shared parse_retry_after refactor in transport.rb and utils.rb into its own pull request.

Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: public_api
size ✓ 643L, 6F substantive, 1346L/9F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (1346L, 9F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head 5cd43aa

@Silthus
Silthus merged commit f71d774 into main Nov 5, 2026
30 checks passed
@Silthus
Silthus deleted the feat/emails-send branch November 5, 2026 12:10
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.

2 participants