Skip to content

feat: send transactional email from posthog templates - #1093

Open
mayteio wants to merge 4 commits into
mainfrom
posthog/transactional-emails
Open

mayteio wants to merge 4 commits into
mainfrom
posthog/transactional-emails

Conversation

@mayteio

@mayteio mayteio commented Oct 21, 2026 •

Copy link
Copy Markdown

💡 Motivation and Context

Note

Safe retries depend on the send API's idempotency key, which landed in PostHog/posthog#120446, PostHog/posthog#120510 and PostHog/posthog#120392. Release this only once those are deployed to every region.

Changes

  • client.emails.send(to=, template_id=, template_data=, distinct_id=, idempotency_key=) on Posthog and AsyncPosthog queues one email from a transactional template and returns its message ID. A result means queued, not delivered.
  • Emails and AsyncEmails in posthog.emails send without a capture client, for processes that only send email or use their own key and host.
  • Sends go to the app host. us.i.posthog.com and eu.i.posthog.com map to us.posthog.com and eu.posthog.com. The path uses @current, so no project ID is needed.
  • client.emails refuses any host that is not PostHog Cloud with app_host_required, before any request, unless the client sets app_host. A custom host is often an ingestion proxy, which must not receive the secret key.
  • email_request_timeout_seconds and email_request_max_retries on the client configure sends, next to the existing feature_flags_request_* options.
  • Emails(session=...) and AsyncEmails(http_client=...) accept your own transport, so tests can assert a send without private hooks.
  • The posthog.emails module docs hold the setup quickstart: keys and scope, the template ID, hosts, retries, the errors a setup usually hits, cleanup and testing.
  • Every send carries an Idempotency-Key. The client makes a UUID when the caller gives none.
  • The client retries network errors, a rate-limit 429, an in-flight 409 and a 503, with the same key, for at most 3 attempts.
  • quota_limited is never retried. A Retry-After over 30 seconds raises instead of waiting. max_retries=0 leaves retries to a job runner.
  • Errors raise EmailError with PostHog's code and a Resend-style name. A disabled client sends nothing and returns id=None.
  • Two choices differ from the contract in Transactional email SDK contract for posthog-python and posthog-node posthog#120921: Python raises instead of returning {data, error}, and v1 sends by template UUID to /messaging_templates/{id}/send/ until the key-based route lands. Both are in the contract comment.
  • Mechanical: determine_app_host in request.py, an auth parameter on the local requests stub, the public API snapshot, and a changeset.

💚 How did you test it?

  • posthog/test/test_emails.py catches these regressions: a retried quota_limited 429 or template_outdated 409, an idempotency key that changes between retries, a secret key sent to an ingestion host, a disabled client that still sends, and Decimal or datetime values in template_data that fail to encode.
  • End to end, sync and async sends ran against a local PostHog dev stack. The emails arrived in MailDev with the template data filled in. A 404, a 400 on to and a 401 mapped to the right name.
  • Sends accepted while the workflow worker was down arrived after it restarted.
  • Not checked against a real server: the 503 retry, because the send API returns 503 only when its Postgres queue is down. Unit tests and a local HTTP server cover it.
  • Against current master, a second send with the same key returned the first send's ID with replayed=True, and MailDev received one email. The same key with another body raised idempotency_key_reused as invalid_idempotent_request.

📝 Checklist

  • 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 Opus 5.5 (claude-opus-5-5) in a PostHog Desktop cloud task, directed by the assignee. Skills used: writing-pr-descriptions.
  • Emails is a standalone class that client.emails wraps, because Client.disabled is true without a project token. An email-only process would otherwise never send.
  • One EmailError class carries code and name. Subclasses can come later without breaking callers.
  • template_data goes through the same clean() as capture properties, so Decimal, UUID and datetimes encode the same way.

Created with PostHog Desktop

🤖 Generated with Claude Code

Add posthog.emails.send() on Client and AsyncClient, plus standalone Emails and AsyncEmails in posthog.emails for processes that only send email. Sends go to the app host with a project secret API key and an Idempotency-Key, and retry network errors, rate limits, an in-flight key and 503 with the same key. Errors raise EmailError with PostHog's code and a Resend-style name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c62b30cd-eb2e-496c-a73d-2a6e7a52a62e
@mayteio mayteio self-assigned this Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-10-22T10:04:26.342980+00:00
Duration: 259879ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 518ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 510ms
Required Headers.Has Authorization Bearer Header ✅ 511ms
Required Headers.Has Content Type Json ✅ 510ms
Required Headers.Has Posthog Sdk Info Format ✅ 510ms
Required Headers.Has Posthog Attempt Header ✅ 510ms
Required Headers.Has Posthog Request Id ✅ 510ms
Required Headers.Has Posthog Request Timestamp ✅ 511ms
Required Headers.Has User Agent ✅ 510ms
Body Format.Body Has Created At And Batch ✅ 510ms
Body Format.No Api Key In Body ✅ 510ms
Body Format.No Sent At In Body ✅ 510ms
Event Format.Event Has Required Root Fields ✅ 510ms
Event Format.Event Uuid Is Valid ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 ✅ 510ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 522ms
Event Format.Distinct Id Is String ✅ 509ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 510ms
Event Format.Set Properties Preserved ✅ 510ms
Event Format.Set Once Properties Preserved ✅ 510ms
Event Format.Groups Properties Preserved ✅ 510ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 510ms
Event Format.Event Has Required Root Fields Batch ✅ 513ms
Event Format.Event Uuid Is Valid Batch ✅ 513ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 514ms
Event Format.Distinct Id Is String Batch ✅ 513ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 514ms
Event Format.Custom Properties Preserved Batch ✅ 514ms
Event Format.Set Properties Preserved Batch ✅ 513ms
Event Format.Set Once Properties Preserved Batch ✅ 514ms
Event Format.Groups Properties Preserved Batch ✅ 514ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 512ms
Batch Behavior.Multiple Events In Single Batch ✅ 518ms
Batch Behavior.Batch Envelope Smoke ✅ 516ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 506ms
Batch Behavior.Flush At Triggers Batch ✅ 1011ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 511ms
Deduplication.Generates Unique Uuids ✅ 518ms
Deduplication.Different Events Same Content Different Uuids ✅ 512ms
Deduplication.Preserves Uuid On Retry ✅ 6519ms
Deduplication.Preserves Timestamp On Retry ✅ 6516ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6524ms
Deduplication.No Duplicate Events In Batch ✅ 518ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 511ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13523ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6520ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3020ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6520ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 510ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 514ms
Response Format Validation.Success No Retry After When All Ok ✅ 512ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1516ms
Response Format Validation.Success No Retry After When Drop Only ✅ 512ms
Response Format Validation.Response Echoes Request Id ✅ 509ms
Retry Behavior.Retries On 408 ✅ 6518ms
Retry Behavior.Retries On 500 ✅ 6519ms
Retry Behavior.Retries On 503 ✅ 8523ms
Retry Behavior.Retries On 504 ✅ 6515ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3516ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11523ms
Retry Behavior.Does Not Retry On 400 ✅ 2514ms
Retry Behavior.Does Not Retry On 401 ✅ 2513ms
Retry Behavior.Does Not Retry On 402 ✅ 2514ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2514ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2513ms
Retry Behavior.Implements Backoff ✅ 22531ms
Retry Behavior.Max Retries Respected ✅ 22536ms
Partial Batch Handling.Handles 200 Full Success ✅ 2513ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3516ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3515ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3517ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6516ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6522ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6521ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6520ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6521ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8521ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3516ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3520ms
Compression.Sends Gzip Content Encoding ✅ 511ms
Compression.No Content Encoding When Disabled ✅ 511ms
Compression.Compressed Body Is Decompressible ✅ 510ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2512ms
Event Options.Cookieless Mode Override ✅ 511ms
Event Options.Disable Skew Correction Override ✅ 510ms
Event Options.Process Person Profile Override ✅ 510ms
Event Options.Product Tour Id Override ✅ 510ms
Event Options.Unset Options Omitted ✅ 510ms
Event Options.Options Override In Batch ✅ 514ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 510ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 510ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 510ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 511ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 510ms
Identity.Capture Ai Event Has Uuid ✅ 509ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 510ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 510ms

Feature_Flags Tests

✅ 17/17 tests passed

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

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 70ms
Versioned Boolean Matching.Matching Version 1 ✅ 69ms
Versioned Boolean Matching.Matching Version 2 ✅ 69ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 37ms

@mayteio
mayteio marked this pull request as ready for review October 21, 2026 12:48
@mayteio
mayteio requested a review from a team as a code owner October 21, 2026 12:48
@mayteio

mayteio commented Oct 21, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@mayteio PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed d2827ab: 1 Must fix, 4 Should fix, 1 Consider.

Comment thread posthog/client.py Outdated
```
"""
if self._emails is None:
self._emails = Emails(self.secret_key, host=self.host)

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.

P1 · Require an explicit app host for custom ingestion hosts

When Client.host points to an ingestion reverse proxy, emails reuses that address and sends the project secret key in its Authorization header. This exposes privileged credentials to a proxy configured only for public capture traffic; AsyncClient.emails does the same. Derive known Cloud app hosts and require an explicit app host for other addresses.

Comment thread posthog/client.py
Comment on lines +1136 to +1137
@property
def emails(self) -> Emails:

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.

P2 · Forward module-level email sends to the default client

With the SDK's module-level setup (import posthog), the advertised posthog.emails.send(...) raises AttributeError: importing the new submodule binds posthog.emails to a module without send. Adding Client.emails only supports explicitly constructed clients. Add a module-level forwarding function that uses the configured default client.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved in 275a64d by not advertising it: every example and the release note now use client.emails.send(). There is deliberately no module-level forwarder, because the default module client is usually set up without a secret_key, so posthog.emails stays the module that holds Emails, AsyncEmails and EmailError.

Comment thread posthog/async_client.py
Comment on lines +270 to +273
if self._emails is None:
self._emails = AsyncEmails(self.secret_key, host=self.host)
self._emails.disabled = self.disabled
return self._emails

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.

P2 · Close owned email transports during client shutdown

After client.emails.send(), leaving async with AsyncPosthog(...) never closes the separate email HTTP client: shutdown() closes only _http_client. Repeated client lifetimes retain email connections; the sync wrapper also omits cleanup. Wire both email transports into parent shutdown, following the lifecycle contract required by AGENTS.md:20.

Comment thread posthog/emails.py Outdated
Comment on lines +358 to +359
self._session: Optional[requests.Session] = None
self._session_lock = threading.Lock()

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.

P2 · Reinitialize email sessions and locks after a fork

When a process forks after creating Emails, children inherit its HTTP pool and _session_lock. A lock held by another parent thread stays locked forever in the child, so _get_session() hangs before the request timeout applies. Client._reinit_after_fork() does not reset email state; replace the lock and session in a child fork hook.

Comment thread posthog/client.py Outdated
"""
if self._emails is None:
self._emails = Emails(self.secret_key, host=self.host)
self._emails.disabled = self.disabled

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.

P2 · Check the parent client's disabled state when sending

When an application stores emails = client.emails and later sets client.disabled = True, emails.send(...) still sends email because this property only copies the disabled state when accessed. AsyncClient.emails has the same bypass, violating the disabled-client contract for retained helpers. Read the parent's current disabled state inside each send.

Comment thread posthog/emails.py Outdated
Comment on lines +275 to +276
backoff = min(_MAX_RETRY_DELAY, 2.0**attempt) * random.uniform(0.8, 1.2)
return max(backoff, error.retry_after or 0.0)

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.

P3 · Apply the retry delay ceiling after jitter

With max_retries=6 or higher, a sustained network failure reaches attempt 5, where _retry_delay() caps the backoff at 30 seconds and then multiplies it by jitter. This produces waits up to 36 seconds, exceeding _MAX_RETRY_DELAY and extending blocking sends beyond the intended ceiling; cap the backoff after applying jitter.

…ports

client.emails only infers PostHog Cloud app hosts. A custom host raises app_host_required before any request, so an ingestion proxy never receives the project secret key. A kept emails reference now reads the client's disabled state on every send. Shutdown closes the email transport, a forked child drops the parent's email session, and the retry ceiling applies after jitter. Docs examples use client.emails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c62b30cd-eb2e-496c-a73d-2a6e7a52a62e

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated advisory code review.

Comment thread posthog/async_client.py

if self._emails is not None:
try:
await self._emails.aclose()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Drain active email sends before closing their transport — AsyncEmails.send() is not registered in the parent's _inflight_operations, so shutdown reaches this close while an email call is still running. If an already-started request then returns a retryable 503, the send loop retries using its cached, now-closed httpx client and raises an uncaught RuntimeError instead of completing its bounded retry or returning EmailError; shutdown has already reported completion. Wait for admitted email operations before disposing their transport, as existing immediate-capture operations do. Reproduction: reproduced — python -m pytest posthog/test/test_review_candidates.py::test_async_shutdown_drains_inflight_email_before_transport_close --timeout=30 -q fails on reviewed head with RuntimeError: Cannot send a request, as the client has been closed, while the same probe passes at d2827abe107b46c1e71eb72d86e93e2ca96bda0b.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in d20805a. A send through client.emails on AsyncClient now registers as an in-flight operation, the same way capture_immediate does, so shutdown() waits for it (retries included) before it closes the email transport. test_async_shutdown_waits_for_a_send_in_flight_and_then_refuses_sends covers the 503-then-202 case during shutdown.

Comment thread posthog/client.py Outdated
"""
if self._emails is None:
self._emails = _client_emails(
Emails, self.secret_key, self.host, lambda: self.disabled

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Honor terminal parent shutdown before admitting email sends — The namespace checks only self.disabled; permanently shutting down the parent does not set that flag. Consequently a retained client.emails handle, or first namespace access after shutdown, can create a fresh transport and successfully send email after client.shutdown() has completed. The async namespace has the same omission at posthog/async_client.py:274. These sends also escape subsequent cleanup because repeated parent shutdown returns early; parent-owned email admission must honor the terminal lifecycle state, independently of whether standalone email clients support reopening. Reproduction: reproduced — python -m pytest posthog/test/test_review_candidates.py -k 'shutdown_is_terminal' --timeout=30 -q fails both sync and async cases on reviewed head because each sends a POST after parent shutdown, and both pass with the minimal temporary admission control.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in d20805a. Parent shutdown is now terminal for client.emails: a retained handle or a fresh one raises EmailError with code="client_closed" and makes no request, on both Client and AsyncClient. Standalone Emails / AsyncEmails can still reopen after close(). Sync shutdown does not wait for a send already in flight; that send still completes, because a closed requests session stays usable. Covered by test_a_shut_down_client_sends_no_email.

client.emails now asks the parent client to admit each send. After shutdown, a kept or new client.emails raises EmailError with code client_closed and makes no request. On AsyncClient, an admitted send is an in-flight operation, so shutdown waits for it, retries included, before it closes the email transport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c62b30cd-eb2e-496c-a73d-2a6e7a52a62e
@mayteio
mayteio requested a review from marandaneto October 22, 2026 06:50

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Public API / agent-friendliness review against the SDK guidelines. The send signature is readable and Pythonic. The only blocking concern is the advertised module-level posthog.emails.send(...) entry point, which still raises AttributeError; this is already tracked in #1093 (comment), so I have not duplicated that inline comment. All four new inline comments are optional, non-blocking suggestions; addressing them here or in a follow-up is at the author's discretion. Validation at this head: 44 email tests passed, public API snapshot check passed, and a no-network API probe reproduced the missing module entry point.

Comment thread posthog/emails.py Outdated
) -> _EmailsT:
app_host = determine_app_host(client_host)
is_cloud = app_host in (US_APP_ENDPOINT, EU_APP_ENDPOINT)
emails = factory(secret_key, host=app_host if is_cloud else None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Make email configuration explicit (non-blocking, optional) — This factory always uses the email defaults, so Posthog(..., max_retries=0, timeout=0.1).emails still has two retries and a 15-second timeout. The parent's settings are documented for event uploads, so this need not inherit them, but consider a documented email-specific configuration path for both sync and async clients, rather than requiring callers to mutate the returned helper or switch to a standalone client. This would make retry ownership easier for humans and agents to discover, in line with the API-design guidelines. Reproduction: reproduced — the focused pytest probe test_parent_configuration_is_not_email_configuration passed, confirming parent values (0.1, 0) and email values (15, 2).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 275a64d. Posthog and AsyncPosthog take email_request_timeout_seconds and email_request_max_retries, named after the existing feature_flags_request_* options and documented in the client's Args. They're validated at construction, so a bad value fails early instead of on the first send.

Comment thread posthog/emails.py Outdated
```python
from posthog.emails import Emails

emails = Emails("phs_...")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Add a complete setup-and-verification quickstart (non-blocking, optional) — The send example is clear, but the prerequisites are scattered across docstrings. Consider a public quickstart recommending when to use client.emails versus standalone Emails, explaining the project-token/secret-key distinction, hog_flow:write, region, where to obtain a transactional template UUID, posthog[async], cleanup, and handling EmailError/the queued result. Document timeout units and scope, retry defaults, and the 30-second Retry-After policy alongside configuration. Include a way to verify success and diagnose a common configuration error, as suggested by the agent-friendly SDK guidelines. Reproduction: not reproduced — reviewed the changed documentation and examples; a clean-project integration using only published docs was not run.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 275a64d. The posthog.emails module docstring is now a quickstart: when to use client.emails vs Emails, project token vs secret key and hog_flow:write, where the template ID comes from, hosts and app_host, posthog[async], what a result means, timeout units, retry defaults and the 30 s Retry-After policy, the error codes a setup usually hits (missing_secret_key, app_host_required, 401/403, not_found, client_closed), cleanup and testing.

Comment thread posthog/test/test_emails.py Outdated
def emails_with(replies: list[Reply], **options: Any) -> tuple[Emails, FakeAdapter]:
emails = Emails(SECRET_KEY, **options)
adapter = FakeAdapter(replies)
emails._get_session().mount("https://", adapter)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Provide a supported no-network testing path (non-blocking, optional) — These tests need the private _get_session() transport hook, and the async cases replace _client; customers would need the same internal knowledge to assert email payloads or simulate a failure. Consider a documented fake/mock interface or supported transport injection for this feature, potentially as a follow-up. disabled=True prevents delivery but only returns id=None, so it cannot verify the attempted operation. This is the distinction made by the SDK testing guidelines. Reproduction: not reproduced — assessed the public API and test setup by inspection; no separate customer-facing mock integration was exercised.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 275a64d. Emails(session=...) takes a requests.Session and AsyncEmails(http_client=...) an httpx.AsyncClient; a transport you pass in is never closed by the SDK. The tests now use these public options instead of _get_session(), and the quickstart's testing section documents them. The remaining private access is in the client-lifecycle tests, which check the client's own transport.

Comment thread posthog/client.py Outdated

Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope. Sends go to the PostHog Cloud app host of ``host``. For any other
host, or to send email without capturing events, create

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: Consider an app-host override on the existing client (non-blocking, optional) — Refusing to send the secret key to an ambiguous ingestion proxy is the right safety default. The ergonomic tradeoff is that a custom-host customer must create a separate Emails/AsyncEmails instance just to select the app host. Consider the explicit app_host override described in the shared email contract, or record why the standalone-client alternative is preferred. Either is an API decision rather than a security defect in the current refusal behavior; documenting the choice would help agents choose the intended integration path. Reproduction: not reproduced — compared the client property and standalone constructor with the linked contract; no custom-host network request was made.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 275a64d. Both clients take app_host. With it set, client.emails sends there; without it, a non-Cloud host still raises app_host_required before any request, and the error message now points at app_host.

@marandaneto
marandaneto requested a review from a team October 22, 2026 09:18

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

approving to unblock but left a few suggestions

@marandaneto

Copy link
Copy Markdown
Member

@mayteio sdk-wise looks good but i guess your team should look at this as well since we dont have much context about this feature and how its gonna be used by customers, so a product-review would make sense for new stuff
a good follow up would be documenting on https://posthog.com/docs and https://github.com/PostHog/sdk-specs so its easy to port the changes

@mayteio

mayteio commented Oct 22, 2026

Copy link
Copy Markdown
Author

Thanks, its a good shout @marandaneto. I wont merge this until I've figure out what this looks like for customers, and in any case its a good reference for internal use-cases in correctly handling retries/errors.

Posthog and AsyncPosthog take app_host, email_request_timeout_seconds and email_request_max_retries, so client.emails works behind a custom host and a job runner can turn retries off. Emails takes a session and AsyncEmails an http_client, which gives tests a supported no-network path. The posthog.emails module docs now hold a setup quickstart: keys and scope, template ID, hosts, retries, errors to expect, cleanup and testing. The release note names client.emails.send().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c62b30cd-eb2e-496c-a73d-2a6e7a52a62e
@mayteio
mayteio requested a review from marandaneto October 22, 2026 09:59

mayteio commented Oct 22, 2026

Copy link
Copy Markdown
Author

Could network_error split into two codes before this ships? A caller that can fall back to another email provider needs to know whether PostHog might have queued the email.

  • connection_error: the request never reached PostHog. That's requests.exceptions.ConnectionError (which includes ConnectTimeout) and httpx.ConnectError / httpx.ConnectTimeout. PostHog queued nothing, so another provider can send safely.
  • timeout: the request was sent but no response came back. That's requests.exceptions.ReadTimeout and httpx.ReadTimeout. PostHog may have queued the email, so falling back risks a duplicate.

Both can stay retryable with the same idempotency key, so _is_retryable only needs to accept both codes. Today both cases surface as network_error after the last retry, so a caller has to choose between possibly sending twice and possibly sending nothing.

Two smaller things:

  • raise error from None drops the underlying exception. Keeping it as __cause__ would make a failed send easier to debug.
  • A disabled client returns id=None. Calling that out in the send docstring would help, because a caller that treats every returned result as sent drops the email silently.

Context: PostHog's own transactional emails (posthog/email.py) are moving to this API behind a flag, with Customer.io as the fallback, in PostHog/posthog#122019. That PR already matches Emails.send's arguments and EmailError's code / status_code, so it can switch to the SDK once it ships.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants