Skip to content

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

Open
Silthus wants to merge 9 commits into
mainfrom
feat/emails-send
Open

Silthus wants to merge 9 commits into
mainfrom
feat/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown

💡 Motivation and Context

An Elixir server that sends transactional email with PostHog, such as a password reset or a receipt, has no SDK call for it. This PR adds PostHog.Emails, the Elixir port of posthog.emails in posthog-node and posthog-python. send/2 queues one email, from a template made in PostHog or from content your code rendered. get/2 reads what happened to it.

{:ok, %PostHog.Emails.SendResult{id: message_id}} =
  PostHog.Emails.send(
    to: "[email protected]",
    distinct_id: user.id,
    template_key: "password-reset",
    subject: "Reset your password",
    html: rendered_html,
    idempotency_key: "password-reset:#{reset.id}"
  )

{:ok, %PostHog.Emails.Status{status: "delivered"}} = PostHog.Emails.get(message_id)

Contract: PostHog/posthog#120921 and the emails spec in PostHog/sdk-specs. The spec covers the template form. The code form follows posthog-node (PostHog/posthog-js#5509) and posthog-python (PostHog/posthog-python#1171), which add it to the other SDKs.

Impact

Pays into Grow Workflows, Messaging scope, through emails sent from code. Elixir apps can send without a hand-written HTTP client. Expected move: small, up. Run cost: n/a.

Merge Danger

Warning

🟡 Merge risk: medium · one-way door (merge-risk rubric)

  • New public API: PostHog.Emails, its result and error structs, and the config options app_host, email_request_timeout_ms, email_request_max_retries and migrated_from. Once released, these can't change without a breaking release.
  • Release order: the code form needs the server's code arm of send/email/ (feat(cdp): deliver a code send's content on its own transactional email route posthog#127122). Until that deploys, a code send gets a 400. Hold the release until then. The template form already works against production.
  • What limits it: existing capture, flags and error tracking don't change. The only shared change is that the supervisor's registry also keeps the secret key. Sends run only when the app calls PostHog.Emails.
  • Revert: git revert <merge commit> before a release. After a release, a revert removes public API, so deprecate instead.

What changes

  • PostHog.Emails.send/2 takes a keyword list or map in one of two forms. It posts a flat JSON body to POST {app host}/api/projects/@current/messaging/send/email/:
    • Template: template_id (sent in lowercase) and template_data (sent as message_data, %{} when left out).
    • Code: template_key, subject, html and/or text, optional from and reply_to (a string becomes a one-item list), and capture_engagement (sent always, true by default).
    • Both forms: to, distinct_id (never taken from PostHog.set_context/2), idempotency_key (a UUID v4 per call when left out, the same on every retry) and max_retries.
    • A field the chosen form doesn't take returns invalid_form with its field, before any request. When the other form takes it, why and fix name that form.
  • PostHog.Emails.get/2 reads GET /api/projects/@current/messaging/emails/{id}/ into a %PostHog.Emails.Status{} with DateTime times. Unknown statuses pass through.
  • Errors are {:error, %PostHog.Emails.Error{}} with name, code, message, status_code, why, fix, docs_url, field and retry_after_ms. name maps 400, 401, 403, 404, 409, 422, 429 and 503 as the spec's table says.
  • Retries: network errors (a Finch pool checkout timeout included), 409 idempotency_key_in_flight, 429 other than quota_limited, and 503, with about 1 s and then 2 s of jittered backoff, or a longer Retry-After (seconds or an HTTP date). A Retry-After over 30 s fails at once. A call waiting to retry returns client_closed, sending nothing, when the PostHog instance has stopped.
  • Request deadline: email_request_timeout_ms (15 s by default) bounds each whole attempt, connection and response included. The attempt runs in a monitored worker that is killed at the deadline and stopped when its caller dies.
  • Auth and host: Authorization: Bearer <secret_key>. A PostHog Cloud api_host maps to us.posthog.com or eu.posthog.com. Any other host needs app_host, an absolute http or https URL checked at config time, or the call returns missing_app_host, so email requests never send the secret key to an ingestion proxy. secret_key also turns on local flag evaluation against api_host, as it does on main; the docs tell users behind such a proxy to set enable_local_evaluation: false.
  • Docs: the PostHog.Emails moduledoc covers setup, both forms, retries, errors and testing. The config options are documented in PostHog.Config, an Emails group is added to HexDocs, and the README links to it. A minor changeset is included.

💚 How did you test it?

Head da5e56c: CI green, 30 checks across the Elixir 1.17 to 1.20 and OTP 25 to 29 matrix (Wiz Secret Scanner neutral, as on every head). Live-run evidence at this head includes a real template send, delivery and status read against local PostHog master. Review round 9 by GPT-6.1 Sol was clean. The proof judge's remaining finding is the code form, which real PostHog can't serve until its server PRs deploy; Michael accepted that gap. The PostHog Review bot found nothing at cb165cc. Locally on Elixir 1.20 / OTP 29 at 6392363: mix format --check-formatted, mix compile --warnings-as-errors, mix posthog.public_api --check, mix credo --strict (no issues) and mix test (920 passed); after cb165cc the email tests (61) and credo were rerun locally, and CI ran the full matrix on every head. The SDK compliance report's 7 capture and feature-flag failures appear on other open PRs (#231 to #235) too; this PR doesn't touch those areas.

  • Test seam: test/posthog/emails_test.exs drives PostHog.Emails.send/2 and get/2 on a real PostHog.Supervisor, with the repo's Mox PostHog.API.Mock as the HTTP client and an injected sleep to observe backoff. It covers both forms' bodies, every argument check, headers and hosts, retries and their delays, Retry-After, error fields and names, warnings, and status records. A through the default Req client block runs the real PostHog.API.Client against Req.Test for the cases a mock can't show: malformed JSON, no api_key in the body, no gzip, and the request deadline. Worker tests prove that a caller trapping exits keeps a clean mailbox, that the request stops when its caller is killed, and that a worker that dies returns network_error without hurting the caller. Further tests cover a pool checkout timeout being retried, and a retry being dropped with client_closed after the instance stops. test/posthog/emails_default_instance_test.exs runs in the PostHog test group and checks that send/1 and get/1 reach the default instance.
  • Live run at da5e56c (raw output in proof storage). A real BEAM process used the real PostHog.API.Client (Req and Finch) over TCP:
    • Against a local server that records each raw request: the template body, a code send with rendered HTML, reply_to as a list and capture_engagement: true. Also a 503 then 202 retried after about 1 s with the same generated key, a 429 with Retry-After: 60 failing at once with retry_after_ms: 60000, a 422 recipient_suppressed keeping every field, an HTML 502 as api_error, the status read, and a code send with template_data refused before any request.
    • Added after review: a 202 with malformed JSON is one api_error with status 202. A server that hangs 3 s against email_request_timeout_ms: 500 returns network_error after 501 ms. A relative app_host is refused at config time.
    • Against us.posthog.com from api_host: "https://us.i.posthog.com" with a made-up phs_ key: send in both forms and get return the real 401 authentication_failed as name: :missing_api_key, the same answer curl gets.
  • Real send at da5e56c against a local PostHog on current master (PostHog/posthog@c985c0d), with a valid project secret key, a transactional template and Maildev as the mail sink. No reply was scripted:
    • A template send with template_data, distinct_id and an idempotency_key returns {:ok, %SendResult{id: "01a1a10f-…", replayed: false}}. The same send again returns the same id with replayed: true, and only one email arrives.
    • The email arrives in Maildev with the name from template_data filled into the subject and body.
    • get/2 returns not_found for the first few seconds, until the status row is written, then {:ok, %Status{status: "sent"}}.
    • A code-form send returns the real 400 as validation_error on template_id, since master doesn't serve the code form yet. An unknown template id and a made-up message id return not_found.
  • Gap (accepted by Michael): no successful code-form send against real PostHog. Its server PRs (feat(cdp): deliver a code send's content on its own transactional email route posthog#127122 and feat(messaging): send an email with inline html from code posthog#127123) aren't merged yet, and the release waits for them to deploy. The wire body matches posthog-node's, which was live-run against the server code send on feat(node): send email content from code with emails.send posthog-js#5509.

📝 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 sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written by Claude Code, Claude Opus 5.5 (claude-opus-5-5), for Michael, as the Elixir slice of the transactional-email SDK ports.
  • Review: nine GPT-6.1 Sol rounds. Rounds 1 to 4 and 6 found issues, all fixed with tests; rounds 5, 7, 8 and 9 found none. The PostHog Review bot's findings are fixed or answered in their threads.
  • Shutdown: the contract asks that a send after shutdown() fail with client_closed. Stopping the PostHog instance does that, and drops a pending retry with client_closed. It doesn't wait for calls in flight: each send runs synchronously in the calling process and the SDK keeps no email queue, so the caller's own supervision decides when it stops.
Question Answer Who Where
Scope Both send forms and the status read; no local templates, cc, bcc, headers, tags, attachments or rendering dependency 👤 Human comment
PR state Ready for review with StampHog, never merged by the agent; release after the server's code form deploys 👤 Human comment
Proof gap Template form and status read proven by a real send against local PostHog master; the code form's live run accepted as a gap until PostHog/posthog#127122 and PostHog/posthog#127123 deploy (recommended default) 👍 Accepted default comment
Error channel {:ok, _} / {:error, %PostHog.Emails.Error{}}, argument errors included, no raises 🤖 Agent comment
HTTP seam The configured api_client_module with an absolute URL and body:; Req retries, redirects and gzip off 🤖 Agent comment
Secret key Kept in the registry beside the public config, never in PostHog.config/1 🤖 Agent comment
Instance not running client_disabled when the instance isn't running as a call starts; see Shutdown for one that stops during a call 🤖 Agent comment
nil options Count as left out 🤖 Agent comment
Retry-After unit retry_after_ms in milliseconds 🤖 Agent comment
Request deadline A monitored, unlinked worker per attempt, killed at the deadline, with a watchdog that stops it when the caller dies 🤖 Agent comment
Reply decoding The SDK decodes replies itself; malformed JSON is one api_error with the status 🤖 Agent comment
app_host check Absolute http or https URL, checked at config time 🤖 Agent comment
Review rounds Nine Sol rounds; continued past the six-round limit for a test fix, the bot-review fixes and a docs fix 🤖 Agent comment
Shutdown A send or pending retry after the instance stops fails with client_closed, as the contract asks; calls in flight finish in their caller, with no drain 🤖 Agent comment
Pool checkout timeout Finch's checkout raise is a retryable network_error; other raises stay unexpected_error 🤖 Agent comment
Flag polling with secret_key Unchanged from main; documented, with enable_local_evaluation: false for proxies 🤖 Agent comment

Involvement: no grilling rounds, no prototypes; nine GPT-6.1 Sol review rounds, the PostHog Review bot and a proof judge; live-run proof captured, including a real send and status read.

🤖 Generated with Claude Code

Add PostHog.Emails.send/2 for a template made in PostHog (template_id,
template_data) or content the app rendered (template_key, subject, html
and/or text), and PostHog.Emails.get/2 for an email's status. Both use the
project secret key against the app host, with one idempotency key across
retries. New config options: app_host, email_request_timeout_ms,
email_request_max_retries and migrated_from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus Silthus self-assigned this Nov 2, 2026
@Silthus

Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decision record for this port.

  • 👤 Scope Port both send forms (a template by template_id, and rendered content by template_key) and the status read, matching posthog-node and posthog-python. Local templates, cc, bcc, headers, tags and attachments stay out, and the SDK adds no rendering: the app renders React Email itself and passes html.

  • 👤 PR state Ready for review with StampHog, never merged by the agent. A release must wait for the server's code form to deploy, because before that a code send gets a 400.

  • 🤖 Error channel {:ok, result} or {:error, %PostHog.Emails.Error{}}, never a raise. A refused option is an error with an invalid_* code and the name :validation_error, before any request. This follows Node's channel and Elixir's tuple convention, rather than Python's raises.

  • 🤖 HTTP seam Requests go through the configured api_client_module, with the absolute app host URL and a pre-encoded body:. They don't use json:, so the default client can't add api_key to the body. Req's retries, redirects and gzip are off: the SDK owns the retry loop, and a redirect must not carry the secret key. Users who mock PostHog.API.Client with Mox see email requests too.

  • 🤖 Secret key The registry keeps it next to the public config, not inside it, so PostHog.config/1 still never returns it.

  • 🤖 Instance not running A send through a PostHog instance that isn't running as the call starts, or has no api_key, returns client_disabled, because the SDK can't tell a stopped instance from one never started. Shutdown covers an instance that stops during a call.

  • 🤖 nil options An option set to nil counts as left out, like Python's None.

  • 🤖 Retry-After unit retry_after_ms, in milliseconds like the repo's other timeouts, where Python uses seconds.

  • 🤖 Request deadline email_request_timeout_ms bounds the whole request, not only each phase of it. Each attempt runs in a monitored worker that is killed at the deadline. The worker isn't linked, so a crash in it returns network_error instead of killing the caller, and a caller that traps exits gets no exit message. A small watchdog kills the worker when the caller dies, so a cancelled job doesn't send later.

  • 🤖 Reply decoding The SDK decodes reply bodies itself, with Req's decoding off. A 2xx with malformed JSON is one api_error carrying the status, never a retried network error.

  • 🤖 app_host check Config refuses an app_host that isn't an absolute http or https URL, so a relative value can't resolve against the ingestion proxy and hand it the secret key.

  • 🤖 Review rounds Seven GPT-6.1 Sol rounds. Rounds 2 to 4 asked in turn for an unlinked worker, a worker that dies with its caller, and a worker whose crash doesn't kill the caller; the monitored worker with a watchdog meets all three. The run continued past the six-round limit for one read of a one-line test fix, and round 7 found nothing.

  • 🤖 Shutdown The contract asks that a send after shutdown() fail with client_closed. A call waiting to retry checks the PostHog instance first and returns client_closed, sending nothing, when the instance has stopped. The contract doesn't ask the SDK to wait for calls in flight, and this one doesn't: sends run synchronously in the calling process and the SDK keeps no email queue to drain, so the caller's own supervisor governs its shutdown.

  • 🤖 Pool checkout timeout Finch raises, rather than returns, when no pooled connection frees up in time. The SDK treats that one raise as a retryable network_error; any other raise from the client stays unexpected_error.

  • 🤖 Flag polling with secret_key Left as it is on main: secret_key also turns on local flag evaluation against api_host. The docs now say so and point users behind an ingestion proxy to enable_local_evaluation: false, instead of changing flag polling for existing users.

  • 👍 Proof gap A real template send, its delivery, an idempotent replay and a status read pass against local PostHog master with a valid project secret key. The code form can't succeed against real PostHog until its server PRs (feat(cdp): deliver a code send's content on its own transactional email route posthog#127122 and feat(messaging): send an email with inline html from code posthog#127123) deploy, and a real code send returns 400 today. Michael accepted that live-run gap, the recommended default; the release waits for the server.

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

PostHog Review · Standard · reviewed c5c4933: 2 Must fix, 3 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

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.ex Resolved
Comment thread lib/posthog/emails.ex Resolved
Comment thread lib/posthog/emails.ex Resolved
Comment thread lib/posthog/emails.ex Resolved
Comment thread lib/posthog/emails.ex Resolved
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-elixir Compliance Report

Date: 2026-11-02T17:25:21.068115+00:00
Duration: 108402ms

⚠️ Some Tests Failed

40/47 tests passed, 7 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 608ms
Format Validation.Event Has Uuid ✅ 608ms
Format Validation.Event Has Lib Properties ✅ 609ms
Format Validation.Distinct Id Is String ✅ 607ms
Format Validation.Token Is Present ✅ 607ms
Format Validation.Custom Properties Preserved ✅ 607ms
Format Validation.Event Has Timestamp ✅ 608ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ❌ 608ms
Retry Behavior.Retries On 503 ✅ 5612ms
Retry Behavior.Does Not Retry On 400 ✅ 2610ms
Retry Behavior.Does Not Retry On 401 ✅ 2611ms
Retry Behavior.Respects Retry After Header ✅ 5614ms
Retry Behavior.Implements Backoff ✅ 15621ms
Retry Behavior.Retries On 500 ✅ 5613ms
Retry Behavior.Retries On 502 ✅ 5614ms
Retry Behavior.Retries On 504 ✅ 5614ms
Retry Behavior.Max Retries Respected ✅ 15624ms
Deduplication.Generates Unique Uuids ✅ 616ms
Deduplication.Preserves Uuid On Retry ✅ 5613ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10618ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5614ms
Deduplication.No Duplicate Events In Batch ✅ 612ms
Deduplication.Different Events Have Different Uuids ✅ 610ms
Compression.Sends Gzip When Enabled ✅ 608ms
Batch Format.Uses Proper Batch Structure ✅ 608ms
Batch Format.Flush With No Events Sends Nothing ✅ 606ms
Batch Format.Multiple Events Batched Together ✅ 611ms
Error Handling.Does Not Retry On 403 ✅ 2609ms
Error Handling.Does Not Retry On 413 ✅ 2610ms
Error Handling.Retries On 408 ✅ 5614ms

Failures

format_validation.non_utc_event_timestamp_is_converted_to_utc

Event 0 field 'timestamp' instant '2026-11-02T17:23:37.090620Z' != expected '2025-01-02T03:04:05Z'

Feature_Flags Tests

⚠️ 11/17 tests passed, 6 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 7ms
Request Payload.Flags Request Uses V2 Query Param ✅ 5ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 5ms
Request Payload.Flags Request Omits Authorization Header ✅ 4ms
Request Payload.Token In Flags Body Matches Init ✅ 5ms
Request Payload.Groups Round Trip ✅ 5ms
Request Payload.Groups Default To Empty Object ❌ 4ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 5ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 4ms
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 ✅ 606ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 8ms
Request Lifecycle.Mock Response Value Is Returned To Caller ❌ 5ms
Retry Behavior.Retries Flags On 502 ❌ 307ms
Retry Behavior.Retries Flags On 504 ❌ 307ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ❌ 607ms

Failures

request_payload.groups_default_to_empty_object

Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']

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', 'api_key', 'flag_keys_to_evaluate']

request_lifecycle.mock_response_value_is_returned_to_caller

Last action result missing field 'value'. Keys: ['error', 'success']

retry_behavior.retries_flags_on_502

Last action result missing field 'value'. Keys: ['error', 'success']

retry_behavior.retries_flags_on_504

Last action result missing field 'value'. Keys: ['error', 'success']

side_effect_events.get_feature_flag_captures_feature_flag_called_event

Expected 1 events with name '$feature_flag_called', got 0

Silthus and others added 6 commits November 2, 2026 16:00
… absolute app_host

- Turn off Req's body decoding, so a 2xx with malformed JSON is an api_error
  with its status instead of a retried network_error.
- Run each request in a task stopped at email_request_timeout_ms, so the
  timeout bounds the whole request, not only each socket read.
- Validate app_host as an absolute http(s) URL, so a relative value can't
  resolve against the ingestion host and send it the secret key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A linked Task left {:EXIT, pid, :normal} in the mailbox of a caller that
traps exits, which crashes a GenServer without a matching handle_info/2.
The worker is now spawned with a monitor only, and the deadline path waits
for its DOWN message and drops a late reply, so the caller's mailbox stays
clean after a reply and after a timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The unlinked worker outlived a caller killed mid-request, such as by a job
timeout, so the request could still go out after cancellation. The worker
is now spawned linked and monitored in one step. It unlinks before it
replies, and the caller unlinks before it kills the worker at the deadline,
so a caller that traps exits still gets no exit message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A linked worker killed its caller when it crashed. The worker is now only
monitored, so its death returns network_error, and a watchdog kills it when
the caller dies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Other async modules start the default PostHog instance, so asserting it was
not running flaked on CI. The test now starts the instance in the PostHog
group and checks that send/1 and get/1 reach it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A secret key starts the flag definition loader, whose requests could take
the mock's expectations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus
Silthus marked this pull request as ready for review November 2, 2026 17:13
@Silthus
Silthus requested a review from a team as a code owner November 2, 2026 17:13

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

PostHog Review · Standard · reviewed f949515: 1 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread lib/posthog/emails.ex Resolved
…down

Finch raises when no pooled connection frees up in time, which skipped the
retry loop as an unexpected error; it is now a retryable network error.
A call waiting to retry now checks the PostHog instance first and returns
client_closed, without a request, when the instance has stopped.

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

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

PostHog Review · Standard · reviewed cb165cc: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

Email requests never send the secret key to an ingestion proxy, but local
flag evaluation fetches definitions from api_host with the same key. Tell
users behind such a proxy to turn local evaluation off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus Silthus added stamphog and removed stamphog labels Nov 2, 2026

@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 yet — waiting on the conditions below.

Re-add the stamphog label to request another review once you have addressed this.

stamphog won't auto-review this pull request, and a human reviewer needs to take it. Three gates refused it. The deny-list gate matched public_api, because the change adds the PostHog.Emails modules and new entries in public_api.snapshot. The size gate failed because the substantive changes come to 1223 lines against a 1200-line ceiling (2217 lines across 13 files including tests and docs). The tier gate classified it as T2-never, a cross-cutting feature.

Next, ask a human reviewer to look at it. Splitting it won't clear the deny-list or tier refusals, since the public API addition stays in the change. Splitting could still make it easier to review, for example by separating the config and registry changes from the PostHog.Emails modules.

Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: public_api
size ✗ too large for auto-review (1223L substantive in global — ceiling is 1200L; 1223L, 9F total, 2217L/13F incl. docs/generated/snapshots)
tier ✗ classified as T2-never: T2-never (2217L, 13F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head da5e56c

@stamphog stamphog Bot removed the stamphog label Nov 2, 2026

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant