Skip to content

Commit 275a64d

Browse files
authored
feat: configure email sends on the client and document setup
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
1 parent d20805a commit 275a64d

6 files changed

Lines changed: 195 additions & 52 deletions

File tree

‎.sampo/changesets/transactional-emails.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
pypi/posthog: minor
33
---
44

5-
Send transactional email from PostHog email templates with `posthog.emails.send()`, 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. Errors raise `EmailError` with PostHog's error `code` and a Resend-style `name`.
5+
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`.

‎posthog/async_client.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
try_attach_code_variables_to_frames,
7171
)
7272
from .consumer import MAX_MSG_SIZE
73-
from .emails import AsyncEmails, _client_emails
73+
from .emails import AsyncEmails, _client_emails, _validate_options
7474
from .feature_flag_evaluations import (
7575
FeatureFlagEvaluations,
7676
_EvaluatedFlagRecord,
@@ -130,6 +130,9 @@ def __init__(
130130
personal_api_key: Optional[str] = None,
131131
feature_flags_request_timeout_seconds: int = 3,
132132
feature_flags_request_max_retries: int = 1,
133+
app_host: Optional[str] = None,
134+
email_request_timeout_seconds: float = 15,
135+
email_request_max_retries: int = 2,
133136
) -> None:
134137
if flush_at <= 0:
135138
raise ValueError("flush_at must be greater than zero")
@@ -174,6 +177,10 @@ def __init__(
174177
else resolved_secret_key
175178
) or None
176179
self.personal_api_key = self.secret_key
180+
_validate_options(email_request_timeout_seconds, email_request_max_retries)
181+
self.app_host = app_host
182+
self.email_request_timeout_seconds = email_request_timeout_seconds
183+
self.email_request_max_retries = email_request_max_retries
177184
self.feature_flags_request_timeout_seconds = (
178185
feature_flags_request_timeout_seconds
179186
)
@@ -256,9 +263,10 @@ def emails(self) -> AsyncEmails:
256263
Send transactional email from PostHog email templates.
257264
258265
Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
259-
scope, and ``posthog[async]``. Sends go to the PostHog Cloud app host of
260-
``host``. For any other host, create ``posthog.emails.AsyncEmails`` with
261-
the app host. A disabled client sends nothing.
266+
scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog
267+
Cloud app host of ``host``. ``email_request_timeout_seconds`` and
268+
``email_request_max_retries`` configure each send. A disabled or shut-down
269+
client sends nothing. See ``posthog.emails`` for setup.
262270
263271
Examples:
264272
```python
@@ -276,6 +284,9 @@ def emails(self) -> AsyncEmails:
276284
self.host,
277285
lambda: self.disabled,
278286
self._admit_email_send,
287+
app_host=self.app_host,
288+
timeout=self.email_request_timeout_seconds,
289+
max_retries=self.email_request_max_retries,
279290
)
280291
return self._emails
281292

‎posthog/client.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
set_context_session as _context_set_context_session,
5353
tag as _context_tag,
5454
)
55-
from posthog.emails import Emails, _client_emails, _no_op
55+
from posthog.emails import Emails, _client_emails, _no_op, _validate_options
5656
from posthog.exception_capture import ExceptionCapture
5757
from posthog._logging import _configure_posthog_logging
5858
from posthog.exception_utils import (
@@ -722,6 +722,9 @@ def __init__(
722722
_use_ai_lane=False,
723723
_enable_multimodal_capture=False,
724724
traces: Optional[dict] = None,
725+
app_host: Optional[str] = None,
726+
email_request_timeout_seconds: float = 15,
727+
email_request_max_retries: int = 2,
725728
):
726729
"""
727730
Initialize a new PostHog client instance.
@@ -773,6 +776,15 @@ def __init__(
773776
feature_flags_request_max_retries: Number of retries for feature flag
774777
requests after network, transport, or timeout failures. Defaults
775778
to 1. Set to 0 to disable retries.
779+
app_host: Your PostHog app host, such as ``https://posthog.example.com``.
780+
``emails`` sends there. Needed only when ``host`` is not PostHog
781+
Cloud, for example a reverse proxy, which must not receive the
782+
secret key.
783+
email_request_timeout_seconds: Timeout in seconds for each email send
784+
request. Defaults to 15.
785+
email_request_max_retries: Number of retries for an email send after a
786+
network error, a rate limit, or an unavailable service. Defaults to 2.
787+
Set to 0 when a job runner already retries the send.
776788
super_properties: Properties merged into every captured event.
777789
enable_exception_autocapture: Automatically capture uncaught
778790
exceptions.
@@ -1037,6 +1049,10 @@ def __init__(
10371049
else resolved_secret_key
10381050
) or None
10391051
self.personal_api_key = self.secret_key
1052+
_validate_options(email_request_timeout_seconds, email_request_max_retries)
1053+
self.app_host = app_host
1054+
self.email_request_timeout_seconds = email_request_timeout_seconds
1055+
self.email_request_max_retries = email_request_max_retries
10401056
if debug:
10411057
# Ensures that debug level messages are logged when debug mode is on.
10421058
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
@@ -1139,9 +1155,10 @@ def emails(self) -> Emails:
11391155
Send transactional email from PostHog email templates.
11401156
11411157
Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
1142-
scope. Sends go to the PostHog Cloud app host of ``host``. For any other
1143-
host, or to send email without capturing events, create
1144-
``posthog.emails.Emails`` with the app host. A disabled client sends nothing.
1158+
scope. Sends go to ``app_host``, or to the PostHog Cloud app host of
1159+
``host``. ``email_request_timeout_seconds`` and ``email_request_max_retries``
1160+
configure each send. A disabled or shut-down client sends nothing. See
1161+
``posthog.emails`` for setup.
11451162
11461163
Examples:
11471164
```python
@@ -1160,6 +1177,9 @@ def emails(self) -> Emails:
11601177
self.host,
11611178
lambda: self.disabled,
11621179
lambda: None if self._shutdown_requested else _no_op,
1180+
app_host=self.app_host,
1181+
timeout=self.email_request_timeout_seconds,
1182+
max_retries=self.email_request_max_retries,
11631183
)
11641184
return self._emails
11651185

‎posthog/emails.py‎

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,67 @@
11
"""Transactional email, sent from an email template in PostHog.
22
3-
Use ``client.emails`` on a client created with a project secret API key, or
4-
create :class:`Emails` directly in a process that only sends email:
3+
Setup:
4+
5+
1. In PostHog, open the email template under Workflows, make it transactional,
6+
and copy its ID. ``template_id`` is that UUID.
7+
2. Create a project secret API key (``phs_...``) with Workflow write access
8+
(``hog_flow:write``) in your project settings. The project API key
9+
(``phc_...``) captures events but cannot send email.
10+
3. Send from the client you already use, or from :class:`Emails` in a process
11+
that only sends email:
512
613
```python
7-
from posthog.emails import Emails
14+
from posthog import Posthog
815
9-
emails = Emails("phs_...")
10-
result = emails.send(
16+
posthog = Posthog("phc_...", secret_key="phs_...", host="https://eu.i.posthog.com")
17+
result = posthog.emails.send(
1118
to="user@example.com",
1219
template_id="<template UUID>",
13-
template_data={"name": "Ada"},
20+
template_data={"name": "Ada"}, # fills {{ variables.name }}
1421
distinct_id="user_123",
1522
idempotency_key="welcome:user_123",
1623
)
24+
print(result.id) # the queued email
1725
```
1826
19-
A result means PostHog queued the email. It does not mean the email was delivered.
27+
``posthog.emails`` above is the client's property. For async code, use
28+
``AsyncPosthog`` or :class:`AsyncEmails` with ``posthog[async]`` installed.
29+
30+
Where sends go: a client on PostHog Cloud sends to its region's app host,
31+
``us.posthog.com`` or ``eu.posthog.com``. For any other ``host``, such as a
32+
reverse proxy, set ``app_host`` on the client, because the proxy must not
33+
receive the secret key. :class:`Emails` takes the app host as ``host``.
34+
35+
What a result means: PostHog queued the email. Delivery happens later, and the
36+
template's workflow in PostHog shows its sends, deliveries and failures.
37+
38+
Timeouts and retries: each request times out after 15 seconds. A send retries
39+
network errors, rate limits and an unavailable service twice, waiting about 1
40+
and then 2 seconds, or the server's ``Retry-After`` if that is longer. A
41+
``Retry-After`` over 30 seconds raises at once. Every attempt sends the same
42+
idempotency key, so a retry never sends twice. Set
43+
``email_request_max_retries=0`` on the client, or ``max_retries=0`` on
44+
:class:`Emails`, when a job runner already retries the send, and pass an
45+
``idempotency_key`` that names the email, such as ``welcome:user_123``.
46+
47+
Errors: a send that is not queued raises :class:`EmailError`. Branch on
48+
``code``. The ones a setup usually hits:
49+
50+
- ``missing_secret_key``: the client has no ``secret_key``.
51+
- ``app_host_required``: the client's ``host`` is not PostHog Cloud. Set ``app_host``.
52+
- ``authentication_failed`` (401) or ``permission_denied`` (403): the key is
53+
wrong, or it lacks ``hog_flow:write``.
54+
- ``not_found`` (404): the template ID is wrong, or transactional email is not
55+
enabled for the project.
56+
- ``client_closed``: the client is shut down.
57+
58+
Cleanup: ``shutdown()`` on the client closes its email connections. Close a
59+
standalone :class:`Emails` with ``close()`` or a ``with`` block.
60+
61+
Testing without network calls: pass your own transport, ``session`` to
62+
:class:`Emails` or ``http_client`` to :class:`AsyncEmails`, with a fake adapter
63+
or ``httpx.MockTransport``. Any library that mocks ``requests`` or ``httpx``
64+
works too. A disabled client returns a result with ``id=None`` and sends nothing.
2065
"""
2166

2267
from __future__ import annotations
@@ -362,7 +407,9 @@ class Emails(_EmailsBase):
362407
363408
Each send retries network errors, rate limits and an unavailable service up
364409
to ``max_retries`` times, with the same idempotency key. Set
365-
``max_retries=0`` when a job runner already retries the send.
410+
``max_retries=0`` when a job runner already retries the send. ``timeout``
411+
is in seconds, per request. Pass ``session`` to send through your own
412+
``requests.Session``, for example a test double.
366413
"""
367414

368415
def __init__(
@@ -373,6 +420,7 @@ def __init__(
373420
timeout: float = 15,
374421
max_retries: int = 2,
375422
disabled: bool = False,
423+
session: Optional[requests.Session] = None,
376424
) -> None:
377425
super().__init__(
378426
secret_key,
@@ -381,7 +429,8 @@ def __init__(
381429
max_retries=max_retries,
382430
disabled=disabled,
383431
)
384-
self._session: Optional[requests.Session] = None
432+
self._session: Optional[requests.Session] = session
433+
self._owns_session = session is None
385434
self._session_lock = threading.Lock()
386435

387436
def send(
@@ -482,9 +531,9 @@ def _deliver(self, prepared: _PreparedSend) -> SendEmailResult:
482531
raise AssertionError("unreachable")
483532

484533
def close(self) -> None:
485-
"""Close the client's HTTP connections."""
534+
"""Close the HTTP connections this client opened. A ``session`` you passed in stays open."""
486535
with self._session_lock:
487-
if self._session is not None:
536+
if self._session is not None and self._owns_session:
488537
self._session.close()
489538
self._session = None
490539

@@ -510,7 +559,11 @@ def _get_session(self) -> requests.Session:
510559

511560

512561
class AsyncEmails(_EmailsBase):
513-
"""The async counterpart of :class:`Emails`. Needs ``posthog[async]``."""
562+
"""The async counterpart of :class:`Emails`. Needs ``posthog[async]``.
563+
564+
Pass ``http_client`` to send through your own ``httpx.AsyncClient``, for
565+
example one with an ``httpx.MockTransport`` in tests.
566+
"""
514567

515568
def __init__(
516569
self,
@@ -520,6 +573,7 @@ def __init__(
520573
timeout: float = 15,
521574
max_retries: int = 2,
522575
disabled: bool = False,
576+
http_client: Any = None,
523577
) -> None:
524578
super().__init__(
525579
secret_key,
@@ -528,7 +582,8 @@ def __init__(
528582
max_retries=max_retries,
529583
disabled=disabled,
530584
)
531-
self._client: Any = None
585+
self._client: Any = http_client
586+
self._owns_client = http_client is None
532587

533588
async def send(
534589
self,
@@ -600,8 +655,8 @@ async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult:
600655
raise AssertionError("unreachable")
601656

602657
async def aclose(self) -> None:
603-
"""Close the client's HTTP connections."""
604-
if self._client is not None:
658+
"""Close the HTTP connections this client opened. An ``http_client`` you passed in stays open."""
659+
if self._client is not None and self._owns_client:
605660
client, self._client = self._client, None
606661
await client.aclose()
607662

@@ -639,15 +694,31 @@ def _client_emails(
639694
client_host: Optional[str],
640695
is_disabled: Callable[[], bool],
641696
admit: Callable[[], Optional[Callable[[], None]]],
697+
*,
698+
app_host: Optional[str],
699+
timeout: float,
700+
max_retries: int,
642701
) -> _EmailsT:
643-
app_host = determine_app_host(client_host)
644-
is_cloud = app_host in (US_APP_ENDPOINT, EU_APP_ENDPOINT)
645-
emails = factory(secret_key, host=app_host if is_cloud else None)
702+
if app_host:
703+
emails = factory(
704+
secret_key, host=app_host, timeout=timeout, max_retries=max_retries
705+
)
706+
emails._parent_disabled = is_disabled
707+
emails._admit = admit
708+
return emails
709+
inferred_host = determine_app_host(client_host)
710+
is_cloud = inferred_host in (US_APP_ENDPOINT, EU_APP_ENDPOINT)
711+
emails = factory(
712+
secret_key,
713+
host=inferred_host if is_cloud else None,
714+
timeout=timeout,
715+
max_retries=max_retries,
716+
)
646717
if not is_cloud:
647718
# A custom host is often an ingestion proxy, which must not receive the secret key.
648719
emails._host_error = (
649720
"The client's host is not PostHog Cloud, so the client can't tell where to send email. "
650-
"Create posthog.emails.Emails(secret_key, host=<your PostHog app host>) instead."
721+
"Set app_host to your PostHog app host on the client."
651722
)
652723
emails._parent_disabled = is_disabled
653724
emails._admit = admit

0 commit comments

Comments
 (0)