Skip to content
5 changes: 5 additions & 0 deletions .sampo/changesets/transactional-emails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Send transactional email from PostHog email templates with `client.emails.send()` on `Posthog` and `AsyncPosthog`, or with `Emails` and `AsyncEmails` from `posthog.emails` in a process that only sends email. A send needs a project secret API key, goes to the app host, sends an idempotency key, and retries network errors, rate limits and an unavailable service with the same key. Set `app_host`, `email_request_timeout_seconds` and `email_request_max_retries` on the client to configure sends. Errors raise `EmailError` with PostHog's error `code` and a Resend-style `name`.
59 changes: 58 additions & 1 deletion posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import warnings
import weakref
from datetime import datetime, timezone
from typing import Any, Dict, Mapping, Optional, Union
from typing import Any, Callable, Dict, Mapping, Optional, Union
from uuid import UUID, uuid4

from typing_extensions import Unpack
Expand Down Expand Up @@ -70,6 +70,7 @@
try_attach_code_variables_to_frames,
)
from .consumer import MAX_MSG_SIZE
from .emails import AsyncEmails, _client_emails, _validate_options
from .feature_flag_evaluations import (
FeatureFlagEvaluations,
_EvaluatedFlagRecord,
Expand Down Expand Up @@ -129,6 +130,9 @@ def __init__(
personal_api_key: Optional[str] = None,
feature_flags_request_timeout_seconds: float = 3,
feature_flags_request_max_retries: int = 1,
app_host: Optional[str] = None,
email_request_timeout_seconds: float = 15,
email_request_max_retries: int = 2,
) -> None:
if flush_at <= 0:
raise ValueError("flush_at must be greater than zero")
Expand Down Expand Up @@ -173,6 +177,10 @@ def __init__(
else resolved_secret_key
) or None
self.personal_api_key = self.secret_key
_validate_options(email_request_timeout_seconds, email_request_max_retries)
self.app_host = app_host
self.email_request_timeout_seconds = email_request_timeout_seconds
self.email_request_max_retries = email_request_max_retries
self.feature_flags_request_timeout_seconds = (
feature_flags_request_timeout_seconds
)
Expand Down Expand Up @@ -247,6 +255,48 @@ def __init__(
self._duplicate_client_registry_key: Optional[tuple[str, str]] = None
self._register_duplicate_client()

_emails: Optional[AsyncEmails] = None

@property
def emails(self) -> AsyncEmails:
"""
Send transactional email from PostHog email templates.

Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog
Cloud app host of ``host``. ``email_request_timeout_seconds`` and
``email_request_max_retries`` configure each send. A disabled or shut-down
client sends nothing. See ``posthog.emails`` for setup.

Examples:
```python
await client.emails.send(
to="user@example.com",
template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
distinct_id="user_123",
)
```
"""
if self._emails is None:
self._emails = _client_emails(
AsyncEmails,
self.secret_key,
self.host,
lambda: self.disabled,
self._admit_email_send,
app_host=self.app_host,
timeout=self.email_request_timeout_seconds,
max_retries=self.email_request_max_retries,
)
return self._emails
Comment on lines +280 to +291

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.

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 5a81665. shutdown() on Client and AsyncClient closes the email transport the client owns. f97420d also makes the async shutdown wait for an email send in flight before it closes the transport.


def _admit_email_send(self) -> Optional[Callable[[], None]]:
# Shutdown waits for in-flight operations before it closes the email transport.
if not self._accepting or self._closed:
return None
completion = self._start_inflight_operation()
return lambda: self._finish_inflight_operation(completion)

async def __aenter__(self) -> AsyncClient:
self._ensure_workers_started()
return self
Expand Down Expand Up @@ -1217,6 +1267,13 @@ async def shutdown(self) -> None:
self.log.exception("Failed to close async capture transport")
errors.append(error)

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.

except Exception as error:
self.log.exception("Failed to close the async email transport")
errors.append(error)

try:
self._unregister_duplicate_client()
except Exception as error:
Expand Down
61 changes: 61 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
set_context_session as _context_set_context_session,
tag as _context_tag,
)
from posthog.emails import Emails, _client_emails, _no_op, _validate_options
from posthog.exception_capture import ExceptionCapture
from posthog._logging import _configure_posthog_logging
from posthog.exception_utils import (
Expand Down Expand Up @@ -722,6 +723,9 @@ def __init__(
_use_ai_lane=False,
_enable_multimodal_capture=False,
traces: Optional[dict] = None,
app_host: Optional[str] = None,
email_request_timeout_seconds: float = 15,
email_request_max_retries: int = 2,
):
"""
Initialize a new PostHog client instance.
Expand Down Expand Up @@ -773,6 +777,15 @@ def __init__(
feature_flags_request_max_retries: Number of retries for feature flag
requests after network, transport, or timeout failures. Defaults
to 1. Set to 0 to disable retries.
app_host: Your PostHog app host, such as ``https://posthog.example.com``.
``emails`` sends there. Needed only when ``host`` is not PostHog
Cloud, for example a reverse proxy, which must not receive the
secret key.
email_request_timeout_seconds: Timeout in seconds for each email send
request. Defaults to 15.
email_request_max_retries: Number of retries for an email send after a
network error, a rate limit, or an unavailable service. Defaults to 2.
Set to 0 when a job runner already retries the send.
super_properties: Properties merged into every captured event.
enable_exception_autocapture: Automatically capture uncaught
exceptions.
Expand Down Expand Up @@ -1037,6 +1050,10 @@ def __init__(
else resolved_secret_key
) or None
self.personal_api_key = self.secret_key
_validate_options(email_request_timeout_seconds, email_request_max_retries)
self.app_host = app_host
self.email_request_timeout_seconds = email_request_timeout_seconds
self.email_request_max_retries = email_request_max_retries
if debug:
# Ensures that debug level messages are logged when debug mode is on.
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
Expand Down Expand Up @@ -1131,6 +1148,42 @@ def _set_library_identity(self, library_id: str, library_version: str) -> None:
def _request_identity_kwargs(self) -> Dict[str, str]:
return {"_user_agent": self._sdk_info} if self._sdk_info != _USER_AGENT else {}

_emails: Optional[Emails] = None

@property
def emails(self) -> Emails:
Comment on lines +1153 to +1154

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.

"""
Send transactional email from PostHog email templates.

Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope. Sends go to ``app_host``, or to the PostHog Cloud app host of
``host``. ``email_request_timeout_seconds`` and ``email_request_max_retries``
configure each send. A disabled or shut-down client sends nothing. See
``posthog.emails`` for setup.

Examples:
```python
client.emails.send(
to="user@example.com",
template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
template_data={"name": "Ada"},
distinct_id="user_123",
)
```
"""
if self._emails is None:
self._emails = _client_emails(
Emails,
self.secret_key,
self.host,
lambda: self.disabled,
lambda: None if self._shutdown_requested else _no_op,

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 · Wait for admitted synchronous email sends before shutdown

When another thread calls shutdown() during emails.send(), this callback registers no active operation, so shutdown closes the session and returns before delivery finishes. Process teardown then loses unfinished sends. Track admitted sends and wait before cleanup, following the lifecycle contract checked under AGENTS.md:21.

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.

Not changing this. A sync shutdown() does not wait for a send that another thread already started, which matches the decision in #1093 (comment). Closing the requests session does not break a request in flight: the checked-out connection stays open, and a retry opens a new pool, so the send completes. Once shutdown is requested, a new send fails with client_closed. A send can only be lost if the caller runs it on a daemon thread and lets the process exit, and the capture lanes behave the same way at exit.

app_host=self.app_host,
timeout=self.email_request_timeout_seconds,
max_retries=self.email_request_max_retries,
)
return self._emails

@property
def queue(self) -> Queue:
"""The analytics lane's queue (kept for backwards compatibility)."""
Expand Down Expand Up @@ -2320,6 +2373,8 @@ def _reinit_after_fork(self):
self._lifecycle_lock = threading.Lock()
self._lifecycle_condition = threading.Condition(self._lifecycle_lock)
self._lifecycle_owner = None
# The parent's email session and its lock are not safe to share with the child.
self._emails = None
self._deferred_lifecycle_thread_pending = False
self._deferred_lifecycle_dirty = False
self._lifecycle_callback_context = ContextVar(
Expand Down Expand Up @@ -2958,6 +3013,12 @@ def _join_once(
self._shutdown_flag_definition_cache_provider,
errors,
)
if self._emails is not None:
self._run_lifecycle_cleanup(
"Failed to close the email transport during lifecycle cleanup",
self._emails.close,
errors,
)
self._run_lifecycle_cleanup(
"Failed to unregister client during lifecycle cleanup",
self._unregister_duplicate_client,
Expand Down
Loading
Loading