From 9102f8cfd6b540842685a0588d8fe37ded5bbc0c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:11:13 +0000 Subject: [PATCH 01/11] feat: send rendered email content and local templates by key Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 313 +++++++++++++++++++++++++++++++----- posthog/test/test_emails.py | 113 +++++++++++++ 2 files changed, 382 insertions(+), 44 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index d6d66560a..e38322786 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -67,6 +67,7 @@ from __future__ import annotations import asyncio +import hashlib import importlib import json import logging @@ -77,10 +78,21 @@ import time from dataclasses import dataclass from types import TracebackType -from typing import Any, Callable, Mapping, Optional, TypeVar, Union +from typing import ( + Any, + Callable, + Mapping, + Optional, + Sequence, + TypedDict, + TypeVar, + Union, + overload, +) from uuid import UUID, uuid4 import requests +from typing_extensions import NotRequired, Unpack from .capture_v1 import _parse_retry_after from .request import ( @@ -92,11 +104,23 @@ from .utils import clean from .version import VERSION -__all__ = ["AsyncEmails", "EmailError", "EmailWarning", "Emails", "SendEmailResult"] +__all__ = [ + "AsyncEmails", + "CodeEmailArgs", + "EmailError", + "EmailWarning", + "Emails", + "SendEmailResult", + "TemplateEmailArgs", +] log = logging.getLogger("posthog") _SEND_PATH = "/api/projects/@current/messaging_templates/{template_id}/send/" +_CODE_SEND_PATH = "/api/projects/@current/messaging/send/email/" +_UPLOAD_PATH = "/api/projects/@current/messaging_templates/by_key/{template_key}/" +_TEMPLATE_KEY = re.compile(r"^[a-zA-Z0-9_-]{1,400}$") +_CONTENT_HASH_HEADER = b"posthog-email-template-v1\n" _MAX_RETRY_DELAY = 30.0 _IDEMPOTENCY_KEY = re.compile(r"^[\x21-\x7e]{1,255}$") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) @@ -152,11 +176,201 @@ class SendEmailResult: warnings: tuple[EmailWarning, ...] = () +class _RecipientArgs(TypedDict): + to: str + distinct_id: NotRequired[Optional[str]] + idempotency_key: NotRequired[Optional[str]] + + +class TemplateEmailArgs(_RecipientArgs): + """A send of an email template made in PostHog, named by its ID. + + Args: + to: One recipient email address. + template_id: The UUID of a template made transactional in PostHog. + template_data: Values for the template's ``{{ variables.* }}`` + placeholders. + distinct_id: The recipient's distinct ID in your product. + idempotency_key: 1 to 255 visible ASCII characters. + """ + + template_id: Union[str, UUID] + template_data: NotRequired[Optional[Mapping[str, Any]]] + + +class CodeEmailArgs(_RecipientArgs): + """A send of email content that lives in your code, named by a key. + + Without ``template_data``, the content is rendered for this one recipient + and sent as is. With it, the content is a template: PostHog fills in its + ``{{ variables.* }}`` placeholders, and the client uploads the template + whenever PostHog doesn't hold its content yet. + + Args: + to: One recipient email address. + template_key: Names the email, so its metrics, logs and status group + under it. Letters, digits, ``-`` and ``_``, at most 400. + subject: The subject line. + html: The HTML body. Give ``html``, ``text`` or both. + text: The plain-text body. Without it, PostHog derives it from ``html``. + from_: A verified sender, such as ``"Acme "``. + reply_to: One address or a list of addresses. + capture_engagement: Capture opens and clicks. Without it, PostHog's + default applies, which is on. + template_data: Values for the ``{{ variables.* }}`` placeholders. + distinct_id: The recipient's distinct ID in your product. + idempotency_key: 1 to 255 visible ASCII characters. + """ + + template_key: str + subject: str + html: NotRequired[str] + text: NotRequired[str] + from_: NotRequired[str] + reply_to: NotRequired[Union[str, Sequence[str]]] + capture_engagement: NotRequired[bool] + template_data: NotRequired[Mapping[str, Any]] + + +@dataclass(frozen=True) +class _PreparedUpload: + path: str + body: bytes + + @dataclass(frozen=True) class _PreparedSend: path: str body: bytes idempotency_key: str + upload: Optional[_PreparedUpload] = None + + +def _prepare(args: Mapping[str, Any]) -> _PreparedSend: + is_template_form = "template_id" in args + if is_template_form == ("template_key" in args): + raise TypeError("Pass either template_id or template_key.") + form = TemplateEmailArgs if is_template_form else CodeEmailArgs + unexpected = sorted(set(args) - set(form.__annotations__)) + if unexpected: + raise TypeError( + f"send() got arguments that the {'template' if is_template_form else 'code'} " + f"form doesn't take: {', '.join(unexpected)}." + ) + if "to" not in args: + raise TypeError("send() is missing the argument to.") + if is_template_form: + return _prepare_send( + args["to"], + args["template_id"], + args.get("template_data"), + args.get("distinct_id"), + args.get("idempotency_key"), + ) + return _prepare_code_send(args) + + +def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: + if "subject" not in args: + raise TypeError("send() is missing the argument subject.") + to, distinct_id, idempotency_key = _recipient( + args["to"], args.get("distinct_id"), args.get("idempotency_key") + ) + template_key = args["template_key"] + if not isinstance(template_key, str) or not _TEMPLATE_KEY.match(template_key): + raise ValueError( + "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400." + ) + content = { + "subject": _nonempty_text(args, "subject"), + "html": _optional_text(args, "html"), + "text": _optional_text(args, "text"), + } + if content["html"] is None and content["text"] is None: + raise TypeError("send() needs html, text or both.") + + present = {field: value for field, value in content.items() if value is not None} + payload: dict[str, Any] = {"to": to} + upload = None + if "template_data" in args: + template_data = args["template_data"] + if not isinstance(template_data, Mapping): + raise TypeError("template_data must be a mapping.") + payload["template"] = {"key": template_key, "hash": _content_hash(content)} + payload["message_data"] = clean(dict(template_data)) + upload = _PreparedUpload( + path=_UPLOAD_PATH.format(template_key=template_key), + body=_encode( + { + "name": template_key, + **present, + "if_not_superseded": True, + "if_code_managed": True, + } + ), + ) + else: + payload.update({"template_key": template_key, **present}) + payload.update(_sender(args)) + if distinct_id is not None: + payload["identifiers"] = {"distinct_id": distinct_id} + return _PreparedSend( + path=_CODE_SEND_PATH, + body=_encode(payload), + idempotency_key=idempotency_key, + upload=upload, + ) + + +def _content_hash(content: Mapping[str, Optional[str]]) -> str: + """PostHog's content hash v1 of a template, so a send can say which content it holds.""" + encoded = bytearray(_CONTENT_HASH_HEADER) + for field in ("subject", "preheader", "html", "text"): + value = (content.get(field) or "").replace("\r\n", "\n").replace("\r", "\n") + data = value.encode("utf-8") + encoded += f"{len(data)}:".encode() + data + b"\n" + return "v1:" + hashlib.sha256(encoded).hexdigest() + + +def _sender(args: Mapping[str, Any]) -> dict[str, Any]: + sender: dict[str, Any] = {} + if "from_" in args: + sender["from"] = _nonempty_text(args, "from_") + if "reply_to" in args: + sender["reply_to"] = _addresses(args["reply_to"]) + if "capture_engagement" in args: + if not isinstance(args["capture_engagement"], bool): + raise TypeError("capture_engagement must be True or False.") + sender["capture_engagement"] = args["capture_engagement"] + return sender + + +def _addresses(reply_to: Any) -> list[str]: + addresses = [reply_to] if isinstance(reply_to, str) else reply_to + if ( + not isinstance(addresses, Sequence) + or not addresses + or not all(isinstance(address, str) and address.strip() for address in addresses) + ): + raise ValueError("reply_to must be one address or a list of addresses.") + return list(addresses) + + +def _nonempty_text(args: Mapping[str, Any], name: str) -> str: + value = args[name] + if not isinstance(value, str) or not value: + raise ValueError(f"{name} must be a nonempty string.") + return value + + +def _optional_text(args: Mapping[str, Any], name: str) -> Optional[str]: + return _nonempty_text(args, name) if name in args else None + + +def _encode(payload: Mapping[str, Any]) -> bytes: + return json.dumps( + payload, cls=DatetimeSerializer, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") def _prepare_send( @@ -166,10 +380,7 @@ def _prepare_send( distinct_id: Optional[str], idempotency_key: Optional[str], ) -> _PreparedSend: - if not isinstance(to, str): - raise TypeError("to must be one email address as a string.") - if not to.strip(): - raise ValueError("to must not be empty.") + to, distinct_id, idempotency_key = _recipient(to, distinct_id, idempotency_key) try: template_uuid = ( template_id if isinstance(template_id, UUID) else UUID(template_id) @@ -178,18 +389,6 @@ def _prepare_send( raise ValueError("template_id must be the template's UUID.") from None if template_data is not None and not isinstance(template_data, Mapping): raise TypeError("template_data must be a mapping.") - if distinct_id is not None and ( - not isinstance(distinct_id, str) or not distinct_id - ): - raise ValueError("distinct_id must be a nonempty string.") - if idempotency_key is None: - idempotency_key = str(uuid4()) - elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.match( - idempotency_key - ): - raise ValueError( - "idempotency_key must be 1 to 255 visible ASCII characters without spaces." - ) payload: dict[str, Any] = { "to": to, @@ -204,6 +403,28 @@ def _prepare_send( ) +def _recipient( + to: Any, distinct_id: Any, idempotency_key: Any +) -> tuple[str, Optional[str], str]: + if not isinstance(to, str): + raise TypeError("to must be one email address as a string.") + if not to.strip(): + raise ValueError("to must not be empty.") + if distinct_id is not None and ( + not isinstance(distinct_id, str) or not distinct_id + ): + raise ValueError("distinct_id must be a nonempty string.") + if idempotency_key is None: + idempotency_key = str(uuid4()) + elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.match( + idempotency_key + ): + raise ValueError( + "idempotency_key must be 1 to 255 visible ASCII characters without spaces." + ) + return to, distinct_id, idempotency_key + + def _validate_options(timeout: float, max_retries: int) -> None: if ( isinstance(timeout, bool) @@ -433,16 +654,19 @@ def __init__( self._owns_session = session is None self._session_lock = threading.Lock() - def send( - self, - *, - to: str, - template_id: Union[str, UUID], - template_data: Optional[Mapping[str, Any]] = None, - distinct_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - ) -> SendEmailResult: - """Queue one email from a transactional email template. + @overload + def send(self, **kwargs: Unpack[TemplateEmailArgs]) -> SendEmailResult: ... + + @overload + def send(self, **kwargs: Unpack[CodeEmailArgs]) -> SendEmailResult: ... + + def send(self, **kwargs: Any) -> SendEmailResult: + """Queue one email, from a template in PostHog or from content in your code. + + Pass exactly one form. The template form names a template made in + PostHog with ``template_id``. The code form names the email with + ``template_key`` and passes ``subject`` and ``html``, ``text`` or both: + see :class:`CodeEmailArgs`. Args: to: One recipient email address. @@ -478,11 +702,16 @@ def send( distinct_id="user_123", idempotency_key="welcome:user_123", ) + emails.send( + to="user@example.com", + template_key="password-reset", + subject="Reset your password", + html=rendered_html, + distinct_id="user_123", + ) ``` """ - prepared = _prepare_send( - to, template_id, template_data, distinct_id, idempotency_key - ) + prepared = _prepare(kwargs) if self._is_disabled(): log.debug("PostHog client is disabled, so no email was sent.") return SendEmailResult(id=None) @@ -585,16 +814,14 @@ def __init__( self._client: Any = http_client self._owns_client = http_client is None - async def send( - self, - *, - to: str, - template_id: Union[str, UUID], - template_data: Optional[Mapping[str, Any]] = None, - distinct_id: Optional[str] = None, - idempotency_key: Optional[str] = None, - ) -> SendEmailResult: - """Queue one email from a transactional email template. + @overload + async def send(self, **kwargs: Unpack[TemplateEmailArgs]) -> SendEmailResult: ... + + @overload + async def send(self, **kwargs: Unpack[CodeEmailArgs]) -> SendEmailResult: ... + + async def send(self, **kwargs: Any) -> SendEmailResult: + """Queue one email, from a template in PostHog or from content in your code. Takes the same arguments and raises the same errors as :meth:`Emails.send`. @@ -609,9 +836,7 @@ async def send( ) ``` """ - prepared = _prepare_send( - to, template_id, template_data, distinct_id, idempotency_key - ) + prepared = _prepare(kwargs) if self._is_disabled(): log.debug("PostHog client is disabled, so no email was sent.") return SendEmailResult(id=None) diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 077fdcce0..a45ae8877 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -449,3 +449,116 @@ async def handler(request: httpx.Request) -> httpx.Response: with pytest.raises(EmailError) as raised: await emails.send(to="user@example.com", template_id=TEMPLATE_ID) assert raised.value.code == "client_closed" + + +CODE_SEND_URL = "https://us.posthog.com/api/projects/@current/messaging/send/email/" +HTML = "

Reset it here.

" + + +def send_code(emails: Emails, **overrides: Any): + return emails.send( + **{ + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + **overrides, + } + ) + + +@parameterized.expand( + [ + ("html_only", {"html": HTML}, {"html": HTML}), + ("text_only", {"text": "Reset it here."}, {"text": "Reset it here."}), + ( + "every_field", + { + "html": HTML, + "text": "Reset it here.", + "from_": "Acme ", + "reply_to": "help@example.com", + "capture_engagement": False, + "distinct_id": "user_123", + }, + { + "html": HTML, + "text": "Reset it here.", + "from": "Acme ", + "reply_to": ["help@example.com"], + "capture_engagement": False, + "identifiers": {"distinct_id": "user_123"}, + }, + ), + ( + "several_reply_to", + {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, + {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, + ), + ] +) +def test_rendered_content_is_posted_as_is(_name, overrides, extra_body): + emails, adapter = emails_with([accepted("msg_1")]) + + result = send_code(emails, idempotency_key="reset:user_123", **overrides) + + assert result.id == "msg_1" + [request] = adapter.requests + assert (request.method, request.url) == ("POST", CODE_SEND_URL) + assert request.headers["Idempotency-Key"] == "reset:user_123" + assert json.loads(request.body) == { + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + **extra_body, + } + + +WELCOME = { + "template_key": "welcome", + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", +} +# The server's content_hash v1 test vectors, shared with posthog-node. +WELCOME_HASH = "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6" +WELCOME_CRLF_TEXT_HASH = ( + "v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453" +) +UPLOAD_URL = ( + "https://us.posthog.com/api/projects/@current/messaging_templates/by_key/welcome/" +) + + +def send_local_template(emails: Emails, **overrides: Any): + return emails.send( + **{ + "to": "user@example.com", + **WELCOME, + "template_data": {"first_name": "Jane"}, + **overrides, + } + ) + + +@parameterized.expand( + [ + ("html_only", {}, WELCOME_HASH), + ("crlf_text", {"text": "\r\n\r"}, WELCOME_CRLF_TEXT_HASH), + ] +) +def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash): + emails, adapter = emails_with([accepted("msg_1")]) + + result = send_local_template( + emails, from_="Acme ", distinct_id="user_123", **overrides + ) + + assert result.id == "msg_1" + [request] = adapter.requests + assert (request.method, request.url) == ("POST", CODE_SEND_URL) + assert json.loads(request.body) == { + "to": "user@example.com", + "template": {"key": "welcome", "hash": hash}, + "message_data": {"first_name": "Jane"}, + "from": "Acme ", + "identifiers": {"distinct_id": "user_123"}, + } From 90c44a8e394de466d3b63c4e8af6476e5fc4e9d9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:15:19 +0000 Subject: [PATCH 02/11] feat: upload an outdated local email template and send again Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-code-form.md | 5 + posthog/async_client.py | 2 +- posthog/client.py | 2 +- posthog/emails.py | 146 +++++++++++++++++++++----- posthog/test/test_emails.py | 140 ++++++++++++++++++++++++ references/public_api_snapshot.txt | 16 ++- 6 files changed, 283 insertions(+), 28 deletions(-) create mode 100644 .sampo/changesets/emails-code-form.md diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md new file mode 100644 index 000000000..9082bb204 --- /dev/null +++ b/.sampo/changesets/emails-code-form.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. diff --git a/posthog/async_client.py b/posthog/async_client.py index 74f2d1533..7a1f62269 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -260,7 +260,7 @@ def __init__( @property def emails(self) -> AsyncEmails: """ - Send transactional email from PostHog email templates. + Send transactional email from PostHog email templates or from content in your code. 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 diff --git a/posthog/client.py b/posthog/client.py index fc1368256..1eb1207ac 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1152,7 +1152,7 @@ def _request_identity_kwargs(self) -> Dict[str, str]: @property def emails(self) -> Emails: """ - Send transactional email from PostHog email templates. + Send transactional email from PostHog email templates or from content in your code. 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 diff --git a/posthog/emails.py b/posthog/emails.py index e38322786..8508060fc 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -1,4 +1,4 @@ -"""Transactional email, sent from an email template in PostHog. +"""Transactional email, sent from an email template in PostHog or from content in your code. Setup: @@ -22,6 +22,26 @@ idempotency_key="welcome:user_123", ) print(result.id) # the queued email + +# Content you rendered for this recipient, named by a key +posthog.emails.send( + to="user@example.com", + template_key="password-reset", + subject="Reset your password", + html=rendered_html, + distinct_id="user_123", +) + +# A template in your code. PostHog fills in the variables, and the client +# uploads the template whenever its content changes. +posthog.emails.send( + to="user@example.com", + template_key="welcome", + subject="Welcome, {{ variables.name }}", + html=welcome_html, + template_data={"name": "Ada"}, + distinct_id="user_123", +) ``` ``posthog.emails`` above is the client's property. For async code, use @@ -350,7 +370,9 @@ def _addresses(reply_to: Any) -> list[str]: if ( not isinstance(addresses, Sequence) or not addresses - or not all(isinstance(address, str) and address.strip() for address in addresses) + or not all( + isinstance(address, str) and address.strip() for address in addresses + ) ): raise ValueError("reply_to must be one address or a list of addresses.") return list(addresses) @@ -458,9 +480,9 @@ def _error_name(status: Optional[int], code: str) -> str: return "not_found" if status == 409: return ( - "validation_error" - if code == "template_outdated" - else "concurrent_idempotent_requests" + "concurrent_idempotent_requests" + if code in _IN_FLIGHT_CODES + else "validation_error" ) if status == 422: return "invalid_idempotent_request" @@ -512,8 +534,20 @@ def _parse_response( if isinstance(warning, dict) ), ) + raise _api_error(status, fields, retry_after_header) + + +def _parse_upload(status: int, payload: Any, retry_after_header: Optional[str]) -> None: + if not 200 <= status < 300: + fields = payload if isinstance(payload, dict) else {} + raise _api_error(status, fields, retry_after_header) + + +def _api_error( + status: int, fields: Mapping[str, Any], retry_after_header: Optional[str] +) -> EmailError: code = _text(fields.get("code")) or "api_error" - raise EmailError( + return EmailError( name=_error_name(status, code), code=code, message=_text(fields.get("detail")) or f"PostHog answered HTTP {status}.", @@ -551,23 +585,33 @@ def _no_op() -> None: pass +def _is_outdated(error: EmailError) -> bool: + return error.status_code == 409 and error.code == "template_outdated" + + def _log_warnings(result: SendEmailResult) -> None: for warning in result.warnings: log.warning("PostHog email warning %s: %s", warning.code, warning.message) -def _headers(secret_key: str, idempotency_key: str) -> dict[str, str]: - return { +def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: + headers = { "Authorization": f"Bearer {secret_key}", "Accept": "application/json", "Content-Type": "application/json", - "Idempotency-Key": idempotency_key, "User-Agent": f"posthog-python/{VERSION}", } + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + return headers + + +_ResultT = TypeVar("_ResultT") +_Parse = Callable[[int, Any, Optional[str]], _ResultT] class _BearerAuth(requests.auth.AuthBase): - def __init__(self, secret_key: str, idempotency_key: str) -> None: + def __init__(self, secret_key: str, idempotency_key: Optional[str]) -> None: self._headers = _headers(secret_key, idempotency_key) def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: @@ -722,15 +766,44 @@ def send(self, **kwargs: Any) -> SendEmailResult: finish() def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: - auth = _BearerAuth(self._secret_key(), prepared.idempotency_key) + secret_key = self._secret_key() + auth = _BearerAuth(secret_key, prepared.idempotency_key) + try: + result = self._request( + "POST", prepared.path, prepared.body, auth, _parse_response + ) + except EmailError as error: + if prepared.upload is None or not _is_outdated(error): + raise + self._request( + "PUT", + prepared.upload.path, + prepared.upload.body, + _BearerAuth(secret_key, None), + _parse_upload, + ) + result = self._request( + "POST", prepared.path, prepared.body, auth, _parse_response + ) + _log_warnings(result) + return result + + def _request( + self, + method: str, + path: str, + body: bytes, + auth: _BearerAuth, + parse: _Parse[_ResultT], + ) -> _ResultT: session = self._get_session() - url = self.host + prepared.path + url = self.host + path for attempt in range(self.max_retries + 1): try: response = session.request( - "POST", + method, url, - data=prepared.body, + data=body, auth=auth, timeout=self.timeout, allow_redirects=False, @@ -740,15 +813,13 @@ def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: payload = response.json() except ValueError: payload = None - result = _parse_response( + return parse( response.status_code, payload, response.headers.get("Retry-After"), ) finally: response.close() - _log_warnings(result) - return result except requests.exceptions.RequestException: error = _sdk_error("network_error", "The request to PostHog failed.") except EmailError as exc: @@ -847,28 +918,55 @@ async def send(self, **kwargs: Any) -> SendEmailResult: finish() async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: - headers = _headers(self._secret_key(), prepared.idempotency_key) + secret_key = self._secret_key() + headers = _headers(secret_key, prepared.idempotency_key) + try: + result = await self._request( + "POST", prepared.path, prepared.body, headers, _parse_response + ) + except EmailError as error: + if prepared.upload is None or not _is_outdated(error): + raise + await self._request( + "PUT", + prepared.upload.path, + prepared.upload.body, + _headers(secret_key, None), + _parse_upload, + ) + result = await self._request( + "POST", prepared.path, prepared.body, headers, _parse_response + ) + _log_warnings(result) + return result + + async def _request( + self, + method: str, + path: str, + body: bytes, + headers: Mapping[str, str], + parse: _Parse[_ResultT], + ) -> _ResultT: httpx, client = self._get_client() - url = self.host + prepared.path + url = self.host + path for attempt in range(self.max_retries + 1): try: - response = await client.post( - url, content=prepared.body, headers=headers + response = await client.request( + method, url, content=body, headers=headers ) try: try: payload = response.json() except ValueError: payload = None - result = _parse_response( + return parse( response.status_code, payload, response.headers.get("Retry-After"), ) finally: await response.aclose() - _log_warnings(result) - return result except httpx.RequestError: error = _sdk_error("network_error", "The request to PostHog failed.") except EmailError as exc: diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index a45ae8877..258ead08b 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -562,3 +562,143 @@ def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash "from": "Acme ", "identifiers": {"distinct_id": "user_123"}, } + + +def uploaded(result: str = "created") -> Reply: + return (201, {"id": "tpl_1", "key": "welcome", "result": result}, {}) + + +def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_key(): + emails, adapter = emails_with( + [rejected(409, "template_outdated"), uploaded(), accepted("msg_1")] + ) + + result = send_local_template(emails, text="Hi {{ variables.first_name }}") + + assert result.id == "msg_1" + first_send, upload, second_send = adapter.requests + assert (upload.method, upload.url) == ("PUT", UPLOAD_URL) + assert json.loads(upload.body) == { + "name": "welcome", + "subject": "Welcome, {{ variables.first_name }}", + "html": "

Hi {{ variables.first_name }}

", + "text": "Hi {{ variables.first_name }}", + "if_not_superseded": True, + "if_code_managed": True, + } + assert "Idempotency-Key" not in upload.headers + assert upload.headers["Authorization"] == f"Bearer {SECRET_KEY}" + assert second_send.body == first_send.body + assert ( + second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"] + ) + + +@parameterized.expand( + [ + ("refused_upload", rejected(409, "template_edited_in_posthog"), 2), + ("outdated_again", None, 3), + ] +) +def test_a_local_template_uploads_at_most_once_per_send( + _name, upload_reply, requests_made +): + replies = ( + [rejected(409, "template_outdated"), upload_reply] + if upload_reply + else [ + rejected(409, "template_outdated"), + uploaded(), + rejected(409, "template_outdated"), + ] + ) + emails, adapter = emails_with(replies) + + with pytest.raises(EmailError) as raised: + send_local_template(emails) + + assert (raised.value.status_code, raised.value.name) == (409, "validation_error") + assert len(adapter.requests) == requests_made + + +def test_rendered_content_is_never_uploaded(): + emails, adapter = emails_with([rejected(409, "template_outdated")]) + + with pytest.raises(EmailError): + send_code(emails, html=HTML) + + assert [request.method for request in adapter.requests] == ["POST"] + + +@pytest.mark.asyncio +async def test_async_uploads_an_outdated_local_template_then_sends_again(): + seen: list[httpx.Request] = [] + replies = [ + httpx.Response(409, json={"code": "template_outdated"}), + httpx.Response(201, json={"id": "tpl_1", "result": "created"}), + httpx.Response(202, json={"message_id": "msg_1"}), + ] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return replies.pop(0) + + emails = AsyncEmails(SECRET_KEY) + emails._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + result = await emails.send( + to="user@example.com", **WELCOME, template_data={"first_name": "Jane"} + ) + + assert result.id == "msg_1" + assert [(request.method, str(request.url)) for request in seen] == [ + ("POST", CODE_SEND_URL), + ("PUT", UPLOAD_URL), + ("POST", CODE_SEND_URL), + ] + first_send, upload, second_send = seen + assert json.loads(first_send.content)["template"] == { + "key": "welcome", + "hash": WELCOME_HASH, + } + assert "Idempotency-Key" not in upload.headers + assert second_send.content == first_send.content + assert ( + second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"] + ) + await emails.aclose() + + +@parameterized.expand( + [ + ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError), + ("no_form", {"template_key": None}, TypeError), + ("no_content", {}, TypeError), + ("no_subject", {"subject": None, "html": HTML}, TypeError), + ("from_keyword", {"html": HTML, "from": "a@example.com"}, TypeError), + ( + "key_with_space", + {"template_key": "reset password", "html": HTML}, + ValueError, + ), + ("key_too_long", {"template_key": "k" * 401, "html": HTML}, ValueError), + ("empty_html", {"html": ""}, ValueError), + ("no_reply_to", {"html": HTML, "reply_to": []}, ValueError), + ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), + ] +) +def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error): + emails, adapter = emails_with([]) + args = { + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + **overrides, + } + + with pytest.raises(error): + emails.send( + **{name: value for name, value in args.items() if value is not None} + ) + + assert adapter.requests == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 7bd9a74ce..59f5f0441 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -818,6 +818,14 @@ attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False +attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[bool] +attribute posthog.emails.CodeEmailArgs.from_: NotRequired[str] +attribute posthog.emails.CodeEmailArgs.html: NotRequired[str] +attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Union[str, Sequence[str]]] +attribute posthog.emails.CodeEmailArgs.subject: str +attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Mapping[str, Any]] +attribute posthog.emails.CodeEmailArgs.template_key: str +attribute posthog.emails.CodeEmailArgs.text: NotRequired[str] attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -833,6 +841,8 @@ attribute posthog.emails.EmailWarning.message: str attribute posthog.emails.SendEmailResult.id: Optional[str] attribute posthog.emails.SendEmailResult.replayed: bool = False attribute posthog.emails.SendEmailResult.warnings: tuple[EmailWarning, ...] = () +attribute posthog.emails.TemplateEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]] +attribute posthog.emails.TemplateEmailArgs.template_id: Union[str, UUID] attribute posthog.enable_exception_autocapture = False attribute posthog.enable_exception_autocapture_rate_limiting = False attribute posthog.enable_full_ai_capture = False @@ -1239,10 +1249,12 @@ class posthog.client.Client(project_api_key: str, host=None, debug=False, max_qu class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None) +class posthog.emails.CodeEmailArgs class posthog.emails.EmailError(*, name: str, code: str, message: str, status_code: Optional[int] = None, why: Optional[str] = None, fix: Optional[str] = None, docs_url: Optional[str] = None, field: Optional[str] = None, retry_after: Optional[float] = None) class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None) class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None) class posthog.emails.SendEmailResult(id: Optional[str], replayed: bool = False, warnings: tuple[EmailWarning, ...] = ()) +class posthog.emails.TemplateEmailArgs class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT) @@ -1704,9 +1716,9 @@ method posthog.contexts.ContextScope.set_device_id(device_id: str) method posthog.contexts.ContextScope.set_distinct_id(distinct_id: str) method posthog.contexts.ContextScope.set_session_id(session_id: str) method posthog.emails.AsyncEmails.aclose() -> None -method posthog.emails.AsyncEmails.send(*, to: str, template_id: Union[str, UUID], template_data: Optional[Mapping[str, Any]] = None, distinct_id: Optional[str] = None, idempotency_key: Optional[str] = None) -> SendEmailResult +method posthog.emails.AsyncEmails.send(**kwargs: Any) -> SendEmailResult method posthog.emails.Emails.close() -> None -method posthog.emails.Emails.send(*, to: str, template_id: Union[str, UUID], template_data: Optional[Mapping[str, Any]] = None, distinct_id: Optional[str] = None, idempotency_key: Optional[str] = None) -> SendEmailResult +method posthog.emails.Emails.send(**kwargs: Any) -> SendEmailResult method posthog.exception_capture.ExceptionCapture.capture_exception(exception, metadata=None) method posthog.exception_capture.ExceptionCapture.close() method posthog.exception_capture.ExceptionCapture.exception_handler(exc_type, exc_value, exc_traceback) From 0bdbb010e7d79227125351d906bc211386eff6f7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:29:01 +0000 Subject: [PATCH 03/11] refactor: keep one content map and type-check the email send forms Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 29 ++++++++++++++++++++++----- posthog/emails.py | 21 ++++++++----------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 7b46c0af0..165356ed0 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -15,6 +15,7 @@ import atexit import posthog from posthog import FeatureFlagEvaluations, FlagValue, Posthog +from posthog.emails import AsyncEmails, Emails, SendEmailResult from posthog.ai.evaluations import ( AsyncOfflineEvaluations, BooleanScorerConfig, @@ -40,6 +41,17 @@ span.end() _ = (flag_value, all_flags, enabled, payload, evaluations, active) +emails = Emails("phs_example") +template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}) +rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="

Reset

", from_="Acme ", reply_to=["help@example.com"]) +local_template_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Hi {{ variables.name }}", text="Hi", template_data={"name": "Ada"}) +_ = (template_send, rendered_send, local_template_send) + + +async def async_email_types() -> None: + sent: SendEmailResult = await AsyncEmails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

") + _ = sent + async def offline_evaluation_types( sync_client: OfflineEvaluations, async_client: AsyncOfflineEvaluations @@ -64,15 +76,22 @@ async def offline_evaluation_types( _ = config PY -cat > "$tmp/invalid_offline_types.py" <<'PY' +cat > "$tmp/invalid_types.py" <<'PY' # pyright: strict from posthog.ai.evaluations import BooleanScorerConfig, CategoricalScorerConfig, NumericPassingRule, NumericScorerConfig, OfflineEvaluations +from posthog.emails import AsyncEmails, Emails client = OfflineEvaluations(project_id=123, secret_key="phx_example") NumericPassingRule(operator="gt", threshold=0.8) # expected-type-error NumericScorerConfig(unknown_setting=True) # expected-type-error CategoricalScorerConfig(selection_mode="single") # expected-type-error client.scorers.create(name="Bad config", kind="numeric", config=BooleanScorerConfig(true_is_failure=True)) # expected-type-error +Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_key="welcome", subject="Hi", html="

Hi

") # expected-type-error +Emails("phs_example").send(to="user@example.com", template_key="welcome", html="

Hi

") # expected-type-error + + +async def invalid_async_email() -> None: + await AsyncEmails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", subject="Hi") # expected-type-error PY "$tmp/.venv/bin/python" - <<'PY' > "$tmp/public_api_access.py" @@ -116,8 +135,8 @@ JSON cd "$tmp" "$tmp/.venv/bin/python" -m pyright strict_posthog_types.py public_api_access.py -if "$tmp/.venv/bin/python" -m pyright --outputjson invalid_offline_types.py > invalid_types.json; then - echo "Invalid offline evaluation configurations unexpectedly passed type checking." +if "$tmp/.venv/bin/python" -m pyright --outputjson invalid_types.py > invalid_types.json; then + echo "Invalid offline evaluation configurations and email sends unexpectedly passed type checking." exit 1 fi "$tmp/.venv/bin/python" - <<'PY' @@ -125,11 +144,11 @@ import json from pathlib import Path expected = { - index for index, line in enumerate(Path("invalid_offline_types.py").read_text().splitlines()) + index for index, line in enumerate(Path("invalid_types.py").read_text().splitlines()) if "expected-type-error" in line } diagnostics = json.loads(Path("invalid_types.json").read_text())["generalDiagnostics"] actual = {item["range"]["start"]["line"] for item in diagnostics if item["severity"] == "error"} assert actual == expected, diagnostics -print("Offline evaluation configuration types reject invalid inputs.") +print("Offline evaluation configuration and email send types reject invalid inputs.") PY diff --git a/posthog/emails.py b/posthog/emails.py index 8508060fc..3e89525fe 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -302,14 +302,13 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400." ) content = { - "subject": _nonempty_text(args, "subject"), - "html": _optional_text(args, "html"), - "text": _optional_text(args, "text"), + field: _nonempty_text(args, field) + for field in ("subject", "html", "text") + if field in args } - if content["html"] is None and content["text"] is None: + if "html" not in content and "text" not in content: raise TypeError("send() needs html, text or both.") - present = {field: value for field, value in content.items() if value is not None} payload: dict[str, Any] = {"to": to} upload = None if "template_data" in args: @@ -323,14 +322,14 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: body=_encode( { "name": template_key, - **present, + **content, "if_not_superseded": True, "if_code_managed": True, } ), ) else: - payload.update({"template_key": template_key, **present}) + payload.update({"template_key": template_key, **content}) payload.update(_sender(args)) if distinct_id is not None: payload["identifiers"] = {"distinct_id": distinct_id} @@ -342,11 +341,11 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: ) -def _content_hash(content: Mapping[str, Optional[str]]) -> str: +def _content_hash(content: Mapping[str, str]) -> str: """PostHog's content hash v1 of a template, so a send can say which content it holds.""" encoded = bytearray(_CONTENT_HASH_HEADER) for field in ("subject", "preheader", "html", "text"): - value = (content.get(field) or "").replace("\r\n", "\n").replace("\r", "\n") + value = content.get(field, "").replace("\r\n", "\n").replace("\r", "\n") data = value.encode("utf-8") encoded += f"{len(data)}:".encode() + data + b"\n" return "v1:" + hashlib.sha256(encoded).hexdigest() @@ -385,10 +384,6 @@ def _nonempty_text(args: Mapping[str, Any], name: str) -> str: return value -def _optional_text(args: Mapping[str, Any], name: str) -> Optional[str]: - return _nonempty_text(args, name) if name in args else None - - def _encode(payload: Mapping[str, Any]) -> bytes: return json.dumps( payload, cls=DatetimeSerializer, separators=(",", ":"), ensure_ascii=False From 5d4776fb33ae4e259c9339b9449f6545f5ec9c4e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:28:28 +0000 Subject: [PATCH 04/11] feat: send cc, bcc, headers, tags and attachments with a code send The code form of emails.send takes the new optional fields of the contract. cc and bcc accept one address or a list and are always sent as a list. Attachment bytes are Base64-encoded once, before the retry loop. A list of byte values is refused with a TypeError that names bytes(). None of the fields reach the template upload. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 5 +- .sampo/changesets/emails-code-form.md | 2 + posthog/emails.py | 116 ++++++++++++++++++++++++-- posthog/test/test_emails.py | 102 ++++++++++++++++++++++ references/public_api_snapshot.txt | 13 +++ 5 files changed, 232 insertions(+), 6 deletions(-) diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 165356ed0..170f9ec6d 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -45,7 +45,8 @@ emails = Emails("phs_example") template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}) rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="

Reset

", from_="Acme ", reply_to=["help@example.com"]) local_template_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Hi {{ variables.name }}", text="Hi", template_data={"name": "Ada"}) -_ = (template_send, rendered_send, local_template_send) +invite_send: SendEmailResult = emails.send(to="user@example.com", template_key="invite", subject="Invite", html="

Invite

", cc="team@example.com", bcc=["audit@example.com"], headers={"X-Entity-Ref-ID": "order-123"}, tags=[{"name": "category", "value": "invite"}], attachments=[{"filename": "invite.ics", "content": b"BEGIN:VCALENDAR", "content_type": "text/calendar"}, {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}]) +_ = (template_send, rendered_send, local_template_send, invite_send) async def async_email_types() -> None: @@ -88,6 +89,8 @@ CategoricalScorerConfig(selection_mode="single") # expected-type-error client.scorers.create(name="Bad config", kind="numeric", config=BooleanScorerConfig(true_is_failure=True)) # expected-type-error Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_key="welcome", subject="Hi", html="

Hi

") # expected-type-error Emails("phs_example").send(to="user@example.com", template_key="welcome", html="

Hi

") # expected-type-error +Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", cc="team@example.com") # expected-type-error +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", attachments=[{"filename": "a.txt", "content": [104, 105]}]) # expected-type-error async def invalid_async_email() -> None: diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md index 9082bb204..185873aeb 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -3,3 +3,5 @@ pypi/posthog: minor --- Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. + +A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, which the client encodes as Base64, or a Base64 string. diff --git a/posthog/emails.py b/posthog/emails.py index 3e89525fe..62d0a779f 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -87,6 +87,7 @@ from __future__ import annotations import asyncio +import base64 import hashlib import importlib import json @@ -127,7 +128,9 @@ __all__ = [ "AsyncEmails", "CodeEmailArgs", + "EmailAttachment", "EmailError", + "EmailTag", "EmailWarning", "Emails", "SendEmailResult", @@ -196,6 +199,30 @@ class SendEmailResult: warnings: tuple[EmailWarning, ...] = () +class EmailTag(TypedDict): + """A name and value that PostHog adds to the email's engagement events as ``$email_tags``.""" + + name: str + value: str + + +class EmailAttachment(TypedDict): + """A file sent with the email. + + Args: + filename: The file name the recipient sees, such as ``invite.ics``. + content: The file as bytes, or already Base64 encoded as a string. + content_type: The MIME type. Without it, the type follows the file name. + content_id: Shows the file inline: reference it in ``html`` as + ```` with ``content_id`` ``logo``. + """ + + filename: str + content: Union[bytes, str] + content_type: NotRequired[str] + content_id: NotRequired[str] + + class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] @@ -235,6 +262,12 @@ class CodeEmailArgs(_RecipientArgs): text: The plain-text body. Without it, PostHog derives it from ``html``. from_: A verified sender, such as ``"Acme "``. reply_to: One address or a list of addresses. + cc: One address or a list of addresses that get a visible copy. + bcc: One address or a list of addresses that get a hidden copy. + headers: Extra email headers by name, such as ``X-Entity-Ref-ID``. + tags: Names and values that PostHog adds to the email's engagement + events. + attachments: Files sent with the email. capture_engagement: Capture opens and clicks. Without it, PostHog's default applies, which is on. template_data: Values for the ``{{ variables.* }}`` placeholders. @@ -248,6 +281,11 @@ class CodeEmailArgs(_RecipientArgs): text: NotRequired[str] from_: NotRequired[str] reply_to: NotRequired[Union[str, Sequence[str]]] + cc: NotRequired[Union[str, Sequence[str]]] + bcc: NotRequired[Union[str, Sequence[str]]] + headers: NotRequired[Mapping[str, str]] + tags: NotRequired[Sequence[EmailTag]] + attachments: NotRequired[Sequence[EmailAttachment]] capture_engagement: NotRequired[bool] template_data: NotRequired[Mapping[str, Any]] @@ -355,8 +393,15 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: sender: dict[str, Any] = {} if "from_" in args: sender["from"] = _nonempty_text(args, "from_") - if "reply_to" in args: - sender["reply_to"] = _addresses(args["reply_to"]) + for field in ("reply_to", "cc", "bcc"): + if field in args: + sender[field] = _addresses(field, args[field]) + if "headers" in args: + sender["headers"] = _headers_field(args["headers"]) + if "tags" in args: + sender["tags"] = _tags(args["tags"]) + if "attachments" in args: + sender["attachments"] = _attachments(args["attachments"]) if "capture_engagement" in args: if not isinstance(args["capture_engagement"], bool): raise TypeError("capture_engagement must be True or False.") @@ -364,8 +409,8 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: return sender -def _addresses(reply_to: Any) -> list[str]: - addresses = [reply_to] if isinstance(reply_to, str) else reply_to +def _addresses(field: str, value: Any) -> list[str]: + addresses = [value] if isinstance(value, str) else value if ( not isinstance(addresses, Sequence) or not addresses @@ -373,10 +418,71 @@ def _addresses(reply_to: Any) -> list[str]: isinstance(address, str) and address.strip() for address in addresses ) ): - raise ValueError("reply_to must be one address or a list of addresses.") + raise ValueError(f"{field} must be one address or a list of addresses.") return list(addresses) +def _headers_field(headers: Any) -> dict[str, str]: + if not isinstance(headers, Mapping) or not all( + isinstance(name, str) and isinstance(value, str) + for name, value in headers.items() + ): + raise TypeError("headers must map header names to string values.") + return dict(headers) + + +def _tags(tags: Any) -> list[dict[str, str]]: + if ( + isinstance(tags, str) + or not isinstance(tags, Sequence) + or not all(_is_tag(tag) for tag in tags) + ): + raise TypeError("tags must be a list of {'name': str, 'value': str}.") + return [{"name": tag["name"], "value": tag["value"]} for tag in tags] + + +def _attachments(attachments: Any) -> list[dict[str, str]]: + if isinstance(attachments, (str, bytes)) or not isinstance(attachments, Sequence): + raise TypeError("attachments must be a list of files.") + return [_attachment(index, file) for index, file in enumerate(attachments)] + + +def _attachment(index: int, file: Any) -> dict[str, str]: + name = f"attachments[{index}]" + if not isinstance(file, Mapping) or not {"filename", "content"} <= set(file): + raise TypeError(f"{name} needs a filename and its content.") + unexpected = set(file) - set(EmailAttachment.__annotations__) + if unexpected: + raise TypeError( + f"{name} has fields an attachment doesn't take: {', '.join(sorted(unexpected))}." + ) + if not all(isinstance(file[field], str) for field in file if field != "content"): + raise TypeError( + f"{name} needs its filename, content_type and content_id as strings." + ) + return {**file, "content": _base64_content(name, file["content"])} + + +def _base64_content(name: str, content: Any) -> str: + if isinstance(content, bytes): + return base64.b64encode(content).decode("ascii") + if isinstance(content, str): + return content + raise TypeError( + f"{name}.content must be bytes or a Base64 string. " + "Convert a list of byte values with bytes(content)." + ) + + +def _is_tag(tag: Any) -> bool: + return ( + isinstance(tag, Mapping) + and set(tag) == {"name", "value"} + and isinstance(tag["name"], str) + and isinstance(tag["value"], str) + ) + + def _nonempty_text(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str) or not value: diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 258ead08b..6d844271e 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -685,6 +685,16 @@ def handler(request: httpx.Request) -> httpx.Response: ("empty_html", {"html": ""}, ValueError), ("no_reply_to", {"html": HTML, "reply_to": []}, ValueError), ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), + ( + "cc_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "cc": "a@example.com", + }, + TypeError, + ), ] ) def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error): @@ -702,3 +712,95 @@ def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error ) assert adapter.requests == [] + + +COPIES_HEADERS_AND_TAGS = { + "cc": "manager@example.com", + "bcc": ["audit@example.com", "archive@example.com"], + "headers": {"X-Entity-Ref-ID": "order-123"}, + "tags": [{"name": "category", "value": "password_reset"}], +} +COPIES_HEADERS_AND_TAGS_BODY = { + "cc": ["manager@example.com"], + "bcc": ["audit@example.com", "archive@example.com"], + "headers": {"X-Entity-Ref-ID": "order-123"}, + "tags": [{"name": "category", "value": "password_reset"}], +} + + +def test_rendered_content_carries_copies_headers_and_tags(): + emails, adapter = emails_with([accepted()]) + + send_code(emails, html=HTML, **COPIES_HEADERS_AND_TAGS) + + [request] = adapter.requests + assert json.loads(request.body) == { + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + "html": HTML, + **COPIES_HEADERS_AND_TAGS_BODY, + } + + +def test_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them(): + emails, adapter = emails_with( + [rejected(409, "template_outdated"), uploaded(), accepted()] + ) + + send_local_template(emails, **COPIES_HEADERS_AND_TAGS) + + first_send, upload, _ = adapter.requests + sent = json.loads(first_send.body) + assert {field: sent[field] for field in COPIES_HEADERS_AND_TAGS_BODY} == ( + COPIES_HEADERS_AND_TAGS_BODY + ) + assert not set(COPIES_HEADERS_AND_TAGS_BODY) & set(json.loads(upload.body)) + + +INVITE = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" + + +def test_attachments_are_sent_base64_encoded_once_across_retries(): + emails, adapter = emails_with([rejected(503, "delivery_unavailable"), accepted()]) + + send_code( + emails, + html=HTML, + attachments=[ + { + "filename": "invite.ics", + "content": INVITE, + "content_type": "text/calendar; method=REQUEST", + }, + {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}, + ], + ) + + first, retry = adapter.requests + assert json.loads(first.body)["attachments"] == [ + { + "filename": "invite.ics", + "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=", + "content_type": "text/calendar; method=REQUEST", + }, + {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}, + ] + assert retry.body == first.body + + +@parameterized.expand( + [ + ("list_of_ints", {"filename": "a.txt", "content": [104, 105]}), + ("no_filename", {"content": b"hi"}), + ("unknown_field", {"filename": "a.txt", "content": b"hi", "path": "/tmp/a"}), + ] +) +def test_rejects_an_attachment_that_is_not_a_file(_name, attachment): + emails, adapter = emails_with([]) + + with pytest.raises(TypeError) as raised: + send_code(emails, html=HTML, attachments=[attachment]) + + assert "104" not in str(raised.value) + assert adapter.requests == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 59f5f0441..8d2b100ce 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -818,14 +818,23 @@ attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False +attribute posthog.emails.CodeEmailArgs.attachments: NotRequired[Sequence[EmailAttachment]] +attribute posthog.emails.CodeEmailArgs.bcc: NotRequired[Union[str, Sequence[str]]] attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[bool] +attribute posthog.emails.CodeEmailArgs.cc: NotRequired[Union[str, Sequence[str]]] attribute posthog.emails.CodeEmailArgs.from_: NotRequired[str] +attribute posthog.emails.CodeEmailArgs.headers: NotRequired[Mapping[str, str]] attribute posthog.emails.CodeEmailArgs.html: NotRequired[str] attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Union[str, Sequence[str]]] attribute posthog.emails.CodeEmailArgs.subject: str +attribute posthog.emails.CodeEmailArgs.tags: NotRequired[Sequence[EmailTag]] attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Mapping[str, Any]] attribute posthog.emails.CodeEmailArgs.template_key: str attribute posthog.emails.CodeEmailArgs.text: NotRequired[str] +attribute posthog.emails.EmailAttachment.content: Union[bytes, str] +attribute posthog.emails.EmailAttachment.content_id: NotRequired[str] +attribute posthog.emails.EmailAttachment.content_type: NotRequired[str] +attribute posthog.emails.EmailAttachment.filename: str attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -835,6 +844,8 @@ attribute posthog.emails.EmailError.name = name attribute posthog.emails.EmailError.retry_after = retry_after attribute posthog.emails.EmailError.status_code = status_code attribute posthog.emails.EmailError.why = why +attribute posthog.emails.EmailTag.name: str +attribute posthog.emails.EmailTag.value: str attribute posthog.emails.EmailWarning.code: str attribute posthog.emails.EmailWarning.fix: Optional[str] = None attribute posthog.emails.EmailWarning.message: str @@ -1250,7 +1261,9 @@ class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_erro class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None) class posthog.emails.CodeEmailArgs +class posthog.emails.EmailAttachment class posthog.emails.EmailError(*, name: str, code: str, message: str, status_code: Optional[int] = None, why: Optional[str] = None, fix: Optional[str] = None, docs_url: Optional[str] = None, field: Optional[str] = None, retry_after: Optional[float] = None) +class posthog.emails.EmailTag class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None) class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None) class posthog.emails.SendEmailResult(id: Optional[str], replayed: bool = False, warnings: tuple[EmailWarning, ...] = ()) From a57db358a102aaf4b9dbd3d3c58eadfff28593f3 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:37:57 +0000 Subject: [PATCH 05/11] fix: accept an empty cc, bcc or reply_to list on a code send The server accepts an empty address list, and the SDK checks types only, so a computed empty list of copies no longer raises before the send. Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 8 ++------ posthog/test/test_emails.py | 7 ++++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index 62d0a779f..b7a43b40f 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -411,12 +411,8 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: def _addresses(field: str, value: Any) -> list[str]: addresses = [value] if isinstance(value, str) else value - if ( - not isinstance(addresses, Sequence) - or not addresses - or not all( - isinstance(address, str) and address.strip() for address in addresses - ) + if not isinstance(addresses, Sequence) or not all( + isinstance(address, str) and address.strip() for address in addresses ): raise ValueError(f"{field} must be one address or a list of addresses.") return list(addresses) diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 6d844271e..f8a446695 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -494,6 +494,11 @@ def send_code(emails: Emails, **overrides: Any): {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, ), + ( + "empty_address_lists", + {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, + {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, + ), ] ) def test_rendered_content_is_posted_as_is(_name, overrides, extra_body): @@ -683,7 +688,7 @@ def handler(request: httpx.Request) -> httpx.Response: ), ("key_too_long", {"template_key": "k" * 401, "html": HTML}, ValueError), ("empty_html", {"html": ""}, ValueError), - ("no_reply_to", {"html": HTML, "reply_to": []}, ValueError), + ("blank_cc", {"html": HTML, "cc": " "}, ValueError), ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), ( "cc_on_template_form", From 2b3413d67948da894f1e93cddcdc56a9fdf3620d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:55:00 +0000 Subject: [PATCH 06/11] docs: describe older servers, replays and html-only text on a code send A PostHog that doesn't take a field refuses the send with invalid_request and names it in EmailError.field, which a test now pins. HTML with no text goes out without a plain-text part. Template and code sends share one set of idempotency keys, and a retry replays after its workflow is paused. Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 16 +++++++++++++--- posthog/test/test_emails.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index b7a43b40f..247a01394 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -253,14 +253,20 @@ class CodeEmailArgs(_RecipientArgs): ``{{ variables.* }}`` placeholders, and the client uploads the template whenever PostHog doesn't hold its content yet. + A PostHog version that doesn't take a field yet, such as ``cc``, refuses + the send with ``invalid_request`` and names the field in + ``EmailError.field``. It never drops the field and sends the rest. + Args: to: One recipient email address. template_key: Names the email, so its metrics, logs and status group under it. Letters, digits, ``-`` and ``_``, at most 400. subject: The subject line. html: The HTML body. Give ``html``, ``text`` or both. - text: The plain-text body. Without it, PostHog derives it from ``html``. - from_: A verified sender, such as ``"Acme "``. + text: The plain-text body. Without it, PostHog derives it from + ``html``. HTML with no text in it, such as a single image, goes out + without a plain-text part. + from_: A verified sender, such as ``"Acme "``. reply_to: One address or a list of addresses. cc: One address or a list of addresses that get a visible copy. bcc: One address or a list of addresses that get a hidden copy. @@ -272,7 +278,11 @@ class CodeEmailArgs(_RecipientArgs): default applies, which is on. template_data: Values for the ``{{ variables.* }}`` placeholders. distinct_id: The recipient's distinct ID in your product. - idempotency_key: 1 to 255 visible ASCII characters. + idempotency_key: 1 to 255 visible ASCII characters. Template sends + and code sends share one set of keys: a key already used for + another email, or for a ``template_id`` send, raises + ``idempotency_key_reused``. A retry with the same key returns the + first send's result, also when its workflow was paused since. """ template_key: str diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index f8a446695..326f8d6cc 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -748,6 +748,27 @@ def test_rendered_content_carries_copies_headers_and_tags(): } +def test_a_server_that_does_not_take_a_field_refuses_the_send_and_names_it(): + refused = { + "type": "invalid_request", + "code": "invalid_request", + "detail": "cc: PostHog doesn't take this field on this route.", + "attr": "cc", + } + emails, adapter = emails_with([(400, refused, {}), accepted()]) + + with pytest.raises(EmailError) as raised: + send_code(emails, html=HTML, cc="manager@example.com") + + error = raised.value + assert (error.name, error.code, error.field) == ( + "validation_error", + "invalid_request", + "cc", + ) + assert len(adapter.requests) == 1 + + def test_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them(): emails, adapter = emails_with( [rejected(409, "template_outdated"), uploaded(), accepted()] From e4630f5ee29d3fa6cdff606bc37ca96813114a1a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:25:26 +0000 Subject: [PATCH 07/11] fix: tighten code-send checks from the round-3 review - Base64-encode attachment content from bytearray and memoryview too - Refuse a template_key with a trailing newline before the send - Take an empty html or text when the other body has content - Raise TypeError for a cc, bcc or reply_to that is not text - Inject the async test transport through http_client Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 37 ++++++++++++++-------- posthog/test/test_emails.py | 49 +++++++++++++++++++++++------- references/public_api_snapshot.txt | 2 +- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index 247a01394..e3e2a7a92 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -142,7 +142,7 @@ _SEND_PATH = "/api/projects/@current/messaging_templates/{template_id}/send/" _CODE_SEND_PATH = "/api/projects/@current/messaging/send/email/" _UPLOAD_PATH = "/api/projects/@current/messaging_templates/by_key/{template_key}/" -_TEMPLATE_KEY = re.compile(r"^[a-zA-Z0-9_-]{1,400}$") +_TEMPLATE_KEY = re.compile(r"[a-zA-Z0-9_-]{1,400}") _CONTENT_HASH_HEADER = b"posthog-email-template-v1\n" _MAX_RETRY_DELAY = 30.0 _IDEMPOTENCY_KEY = re.compile(r"^[\x21-\x7e]{1,255}$") @@ -211,14 +211,15 @@ class EmailAttachment(TypedDict): Args: filename: The file name the recipient sees, such as ``invite.ics``. - content: The file as bytes, or already Base64 encoded as a string. + content: The file as ``bytes``, ``bytearray`` or ``memoryview``, or + already Base64 encoded as a string. content_type: The MIME type. Without it, the type follows the file name. content_id: Shows the file inline: reference it in ``html`` as ```` with ``content_id`` ``logo``. """ filename: str - content: Union[bytes, str] + content: Union[bytes, bytearray, memoryview, str] content_type: NotRequired[str] content_id: NotRequired[str] @@ -345,17 +346,18 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: args["to"], args.get("distinct_id"), args.get("idempotency_key") ) template_key = args["template_key"] - if not isinstance(template_key, str) or not _TEMPLATE_KEY.match(template_key): + if not isinstance(template_key, str) or not _TEMPLATE_KEY.fullmatch(template_key): raise ValueError( "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400." ) - content = { - field: _nonempty_text(args, field) - for field in ("subject", "html", "text") - if field in args - } + content = {"subject": _nonempty_text(args, "subject")} + content.update( + {field: _body(args, field) for field in ("html", "text") if field in args} + ) if "html" not in content and "text" not in content: raise TypeError("send() needs html, text or both.") + if not (content.get("html") or content.get("text")): + raise ValueError("html or text must be a nonempty string.") payload: dict[str, Any] = {"to": to} upload = None @@ -422,9 +424,11 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: def _addresses(field: str, value: Any) -> list[str]: addresses = [value] if isinstance(value, str) else value if not isinstance(addresses, Sequence) or not all( - isinstance(address, str) and address.strip() for address in addresses + isinstance(address, str) for address in addresses ): - raise ValueError(f"{field} must be one address or a list of addresses.") + raise TypeError(f"{field} must be one address or a list of addresses.") + if not all(address.strip() for address in addresses): + raise ValueError(f"{field} must not hold a blank address.") return list(addresses) @@ -470,7 +474,7 @@ def _attachment(index: int, file: Any) -> dict[str, str]: def _base64_content(name: str, content: Any) -> str: - if isinstance(content, bytes): + if isinstance(content, (bytes, bytearray, memoryview)): return base64.b64encode(content).decode("ascii") if isinstance(content, str): return content @@ -489,6 +493,13 @@ def _is_tag(tag: Any) -> bool: ) +def _body(args: Mapping[str, Any], name: str) -> str: + value = args[name] + if not isinstance(value, str): + raise TypeError(f"{name} must be a string.") + return value + + def _nonempty_text(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str) or not value: @@ -771,7 +782,7 @@ def _secret_key(self) -> str: class Emails(_EmailsBase): - """Sends transactional email from PostHog email templates. + """Sends transactional email from PostHog email templates or from content in your code. ``host`` is your PostHog app host. PostHog Cloud ingestion hosts map to the app host, so ``us.i.posthog.com`` sends to ``us.posthog.com``. Any other diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 326f8d6cc..2e117f66f 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -548,6 +548,7 @@ def send_local_template(emails: Emails, **overrides: Any): [ ("html_only", {}, WELCOME_HASH), ("crlf_text", {"text": "\r\n\r"}, WELCOME_CRLF_TEXT_HASH), + ("empty_text", {"text": ""}, WELCOME_HASH), ] ) def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash): @@ -569,8 +570,8 @@ def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash } -def uploaded(result: str = "created") -> Reply: - return (201, {"id": "tpl_1", "key": "welcome", "result": result}, {}) +def uploaded() -> Reply: + return (201, {"id": "tpl_1", "key": "welcome", "result": "created"}, {}) def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_key(): @@ -648,12 +649,10 @@ def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return replies.pop(0) - emails = AsyncEmails(SECRET_KEY) - emails._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) - - result = await emails.send( - to="user@example.com", **WELCOME, template_data={"first_name": "Jane"} - ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await AsyncEmails(SECRET_KEY, http_client=client).send( + to="user@example.com", **WELCOME, template_data={"first_name": "Jane"} + ) assert result.id == "msg_1" assert [(request.method, str(request.url)) for request in seen] == [ @@ -671,7 +670,6 @@ def handler(request: httpx.Request) -> httpx.Response: assert ( second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"] ) - await emails.aclose() @parameterized.expand( @@ -686,9 +684,16 @@ def handler(request: httpx.Request) -> httpx.Response: {"template_key": "reset password", "html": HTML}, ValueError, ), + ( + "key_with_trailing_newline", + {"template_key": "welcome\n", "html": HTML}, + ValueError, + ), ("key_too_long", {"template_key": "k" * 401, "html": HTML}, ValueError), ("empty_html", {"html": ""}, ValueError), + ("empty_html_and_text", {"html": "", "text": ""}, ValueError), ("blank_cc", {"html": HTML, "cc": " "}, ValueError), + ("cc_not_text", {"html": HTML, "cc": 123}, TypeError), ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), ( "cc_on_template_form", @@ -815,11 +820,33 @@ def test_attachments_are_sent_base64_encoded_once_across_retries(): assert retry.body == first.body +@parameterized.expand( + [ + ("bytearray", bytearray(INVITE)), + ("memoryview", memoryview(INVITE)), + ] +) +def test_attachment_content_from_any_bytes_buffer_is_base64_encoded(_name, content): + emails, adapter = emails_with([accepted()]) + + send_code( + emails, html=HTML, attachments=[{"filename": "invite.ics", "content": content}] + ) + + [request] = adapter.requests + assert json.loads(request.body)["attachments"] == [ + { + "filename": "invite.ics", + "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=", + } + ] + + @parameterized.expand( [ ("list_of_ints", {"filename": "a.txt", "content": [104, 105]}), - ("no_filename", {"content": b"hi"}), - ("unknown_field", {"filename": "a.txt", "content": b"hi", "path": "/tmp/a"}), + ("no_filename", {"content": b"104"}), + ("unknown_field", {"filename": "a.txt", "content": b"104", "path": "/tmp/a"}), ] ) def test_rejects_an_attachment_that_is_not_a_file(_name, attachment): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 8d2b100ce..c1fd1b560 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -831,7 +831,7 @@ attribute posthog.emails.CodeEmailArgs.tags: NotRequired[Sequence[EmailTag]] attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Mapping[str, Any]] attribute posthog.emails.CodeEmailArgs.template_key: str attribute posthog.emails.CodeEmailArgs.text: NotRequired[str] -attribute posthog.emails.EmailAttachment.content: Union[bytes, str] +attribute posthog.emails.EmailAttachment.content: Union[bytes, bytearray, memoryview, str] attribute posthog.emails.EmailAttachment.content_id: NotRequired[str] attribute posthog.emails.EmailAttachment.content_type: NotRequired[str] attribute posthog.emails.EmailAttachment.filename: str From a6bc0ac5f7fcd2f219c0eee6397d1cb6353f05aa Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:34:44 +0000 Subject: [PATCH 08/11] fix: encode any bytes buffer and type-check subject and from_ - Base64-encode a non-contiguous memoryview through bytes() - Raise TypeError for a subject or from_ that is not a string - Name bytearray and memoryview in the changeset Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-code-form.md | 2 +- posthog/emails.py | 12 ++++++------ posthog/test/test_emails.py | 6 ++++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md index 185873aeb..7a2051647 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -4,4 +4,4 @@ pypi/posthog: minor Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. -A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, which the client encodes as Base64, or a Base64 string. +A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, `bytearray` or `memoryview`, which the client encodes as Base64, or a Base64 string. diff --git a/posthog/emails.py b/posthog/emails.py index e3e2a7a92..69ff9f79e 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -352,7 +352,7 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: ) content = {"subject": _nonempty_text(args, "subject")} content.update( - {field: _body(args, field) for field in ("html", "text") if field in args} + {field: _string(args, field) for field in ("html", "text") if field in args} ) if "html" not in content and "text" not in content: raise TypeError("send() needs html, text or both.") @@ -475,7 +475,7 @@ def _attachment(index: int, file: Any) -> dict[str, str]: def _base64_content(name: str, content: Any) -> str: if isinstance(content, (bytes, bytearray, memoryview)): - return base64.b64encode(content).decode("ascii") + return base64.b64encode(bytes(content)).decode("ascii") if isinstance(content, str): return content raise TypeError( @@ -493,7 +493,7 @@ def _is_tag(tag: Any) -> bool: ) -def _body(args: Mapping[str, Any], name: str) -> str: +def _string(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str): raise TypeError(f"{name} must be a string.") @@ -501,9 +501,9 @@ def _body(args: Mapping[str, Any], name: str) -> str: def _nonempty_text(args: Mapping[str, Any], name: str) -> str: - value = args[name] - if not isinstance(value, str) or not value: - raise ValueError(f"{name} must be a nonempty string.") + value = _string(args, name) + if not value: + raise ValueError(f"{name} must not be empty.") return value diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 2e117f66f..b7bb8167c 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -678,6 +678,8 @@ def handler(request: httpx.Request) -> httpx.Response: ("no_form", {"template_key": None}, TypeError), ("no_content", {}, TypeError), ("no_subject", {"subject": None, "html": HTML}, TypeError), + ("subject_not_text", {"subject": 1, "html": HTML}, TypeError), + ("from_not_text", {"html": HTML, "from_": 1}, TypeError), ("from_keyword", {"html": HTML, "from": "a@example.com"}, TypeError), ( "key_with_space", @@ -824,6 +826,10 @@ def test_attachments_are_sent_base64_encoded_once_across_retries(): [ ("bytearray", bytearray(INVITE)), ("memoryview", memoryview(INVITE)), + ( + "strided_memoryview", + memoryview(bytes(b for byte in INVITE for b in (byte, 0)))[::2], + ), ] ) def test_attachment_content_from_any_bytes_buffer_is_base64_encoded(_name, content): From 8363ebc2d25e147a06f4792d8bec5c93098bf151 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:43:59 +0000 Subject: [PATCH 09/11] feat: align email sends with the shared SDK contract - Raise EmailValidationError (EmailError and ValueError) for local checks, with the contract's code and the argument in field, on both forms - Raise client_disabled from a disabled client, and missing_app_host when the client's host is not PostHog Cloud - Wait up to 30 seconds for a longer Retry-After instead of failing - Take max_retries per send, and capture_engagement on the template form - Send migrated_from as X-PostHog-Migrated-From on every email request - Refuse an idempotency key with a trailing newline - Drop replayed from SendEmailResult Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 4 +- .sampo/changesets/emails-code-form.md | 2 +- .../changesets/emails-contract-alignment.md | 5 + posthog/async_client.py | 10 +- posthog/client.py | 14 +- posthog/emails.py | 303 ++++++++++++------ posthog/test/test_emails.py | 257 +++++++++++---- references/public_api_snapshot.txt | 17 +- 8 files changed, 440 insertions(+), 172 deletions(-) create mode 100644 .sampo/changesets/emails-contract-alignment.md diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 170f9ec6d..7703ab786 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -41,8 +41,8 @@ span.end() _ = (flag_value, all_flags, enabled, payload, evaluations, active) -emails = Emails("phs_example") -template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}) +emails = Emails("phs_example", migrated_from="resend") +template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}, capture_engagement=False, max_retries=0) rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="

Reset

", from_="Acme ", reply_to=["help@example.com"]) local_template_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Hi {{ variables.name }}", text="Hi", template_data={"name": "Ada"}) invite_send: SendEmailResult = emails.send(to="user@example.com", template_key="invite", subject="Invite", html="

Invite

", cc="team@example.com", bcc=["audit@example.com"], headers={"X-Entity-Ref-ID": "order-123"}, tags=[{"name": "category", "value": "invite"}], attachments=[{"filename": "invite.ics", "content": b"BEGIN:VCALENDAR", "content_type": "text/calendar"}, {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}]) diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md index 7a2051647..f9b8d0ac4 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -2,6 +2,6 @@ pypi/posthog: minor --- -Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. +Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `EmailValidationError`. A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, `bytearray` or `memoryview`, which the client encodes as Base64, or a Base64 string. diff --git a/.sampo/changesets/emails-contract-alignment.md b/.sampo/changesets/emails-contract-alignment.md new file mode 100644 index 000000000..bd8cb2bb8 --- /dev/null +++ b/.sampo/changesets/emails-contract-alignment.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +`emails.send` follows the shared email SDK contract. An argument that fails a check raises `EmailValidationError`, an `EmailError` and a `ValueError` with the check's `code` and the argument in `field`. A disabled client raises `client_disabled`, and a client without a usable app host raises `missing_app_host`. A send takes `max_retries`, and a template send takes `capture_engagement`. Set `migrated_from`, such as `"resend"`, on the client to send it with every email request. A `Retry-After` over 30 seconds waits 30 seconds instead of failing the send. `SendEmailResult` has no `replayed` field. diff --git a/posthog/async_client.py b/posthog/async_client.py index 7a1f62269..e0f4fdcb1 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -133,6 +133,7 @@ def __init__( app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, + migrated_from: Optional[str] = None, ) -> None: if flush_at <= 0: raise ValueError("flush_at must be greater than zero") @@ -177,10 +178,13 @@ 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) + _validate_options( + email_request_timeout_seconds, email_request_max_retries, migrated_from + ) self.app_host = app_host self.email_request_timeout_seconds = email_request_timeout_seconds self.email_request_max_retries = email_request_max_retries + self.migrated_from = migrated_from self.feature_flags_request_timeout_seconds = ( feature_flags_request_timeout_seconds ) @@ -266,7 +270,8 @@ def emails(self) -> AsyncEmails: 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. + client sends nothing and raises ``client_disabled`` or ``client_closed``. + See ``posthog.emails`` for setup. Examples: ```python @@ -287,6 +292,7 @@ def emails(self) -> AsyncEmails: app_host=self.app_host, timeout=self.email_request_timeout_seconds, max_retries=self.email_request_max_retries, + migrated_from=self.migrated_from, ) return self._emails diff --git a/posthog/client.py b/posthog/client.py index 1eb1207ac..47c515136 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -725,6 +725,7 @@ def __init__( app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, + migrated_from: Optional[str] = None, ): """ Initialize a new PostHog client instance. @@ -785,6 +786,8 @@ def __init__( 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. + migrated_from: The email provider you moved from, such as + ``"resend"``. ``emails`` sends it with every request. super_properties: Properties merged into every captured event. enable_exception_autocapture: Automatically capture uncaught exceptions. @@ -1049,10 +1052,13 @@ 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) + _validate_options( + email_request_timeout_seconds, email_request_max_retries, migrated_from + ) self.app_host = app_host self.email_request_timeout_seconds = email_request_timeout_seconds self.email_request_max_retries = email_request_max_retries + self.migrated_from = migrated_from 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 @@ -1157,8 +1163,9 @@ def emails(self) -> Emails: 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. + configure each send. A disabled or shut-down client sends nothing and + raises ``client_disabled`` or ``client_closed``. See ``posthog.emails`` + for setup. Examples: ```python @@ -1180,6 +1187,7 @@ def emails(self) -> Emails: app_host=self.app_host, timeout=self.email_request_timeout_seconds, max_retries=self.email_request_max_retries, + migrated_from=self.migrated_from, ) return self._emails diff --git a/posthog/emails.py b/posthog/emails.py index 69ff9f79e..e4f022b5d 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -57,31 +57,41 @@ Timeouts and retries: each request times out after 15 seconds. A send retries network errors, rate limits and an unavailable service twice, waiting about 1 -and then 2 seconds, or the server's ``Retry-After`` if that is longer. A -``Retry-After`` over 30 seconds raises at once. Every attempt sends the same -idempotency key, so a retry never sends twice. Set -``email_request_max_retries=0`` on the client, or ``max_retries=0`` on +and then 2 seconds, or the server's ``Retry-After`` if that is longer, at most +30 seconds. Every attempt sends the same idempotency key, so a retry never +sends twice. Pass ``max_retries=0`` to ``send()``, or set +``email_request_max_retries=0`` on the client or ``max_retries=0`` on :class:`Emails`, when a job runner already retries the send, and pass an ``idempotency_key`` that names the email, such as ``welcome:user_123``. +Moving from another email provider: set ``migrated_from``, such as +``"resend"``, on the client or on :class:`Emails`, so PostHog knows where your +sends came from. + Errors: a send that is not queued raises :class:`EmailError`. Branch on ``code``. The ones a setup usually hits: - ``missing_secret_key``: the client has no ``secret_key``. -- ``app_host_required``: the client's ``host`` is not PostHog Cloud. Set ``app_host``. +- ``missing_app_host``: the client's ``host`` is not PostHog Cloud. Set ``app_host``. - ``authentication_failed`` (401) or ``permission_denied`` (403): the key is wrong, or it lacks ``hog_flow:write``. - ``not_found`` (404): the template ID is wrong, or transactional email is not enabled for the project. +- ``client_disabled``: the client is disabled. - ``client_closed``: the client is shut down. +An argument that fails a check raises :class:`EmailValidationError` before any +request. It is an :class:`EmailError` and a ``ValueError``, its ``code`` is +``invalid_form``, ``invalid_template_id``, ``invalid_template_key`` or +``invalid_idempotency_key``, and ``field`` names the argument. + Cleanup: ``shutdown()`` on the client closes its email connections. Close a standalone :class:`Emails` with ``close()`` or a ``with`` block. Testing without network calls: pass your own transport, ``session`` to :class:`Emails` or ``http_client`` to :class:`AsyncEmails`, with a fake adapter or ``httpx.MockTransport``. Any library that mocks ``requests`` or ``httpx`` -works too. A disabled client returns a result with ``id=None`` and sends nothing. +works too. A disabled client sends nothing and raises ``client_disabled``. """ from __future__ import annotations @@ -97,7 +107,7 @@ import re import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import TracebackType from typing import ( Any, @@ -131,6 +141,7 @@ "EmailAttachment", "EmailError", "EmailTag", + "EmailValidationError", "EmailWarning", "Emails", "SendEmailResult", @@ -145,7 +156,7 @@ _TEMPLATE_KEY = re.compile(r"[a-zA-Z0-9_-]{1,400}") _CONTENT_HASH_HEADER = b"posthog-email-template-v1\n" _MAX_RETRY_DELAY = 30.0 -_IDEMPOTENCY_KEY = re.compile(r"^[\x21-\x7e]{1,255}$") +_IDEMPOTENCY_KEY = re.compile(r"[\x21-\x7e]{1,255}") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) _QUOTA_CODES = frozenset({"quota_limited"}) @@ -183,6 +194,24 @@ def __init__( self.retry_after = retry_after +class EmailValidationError(EmailError, ValueError): + """An argument failed a check before any request, so nothing was sent. + + ``name`` is ``validation_error``. ``code`` says which check failed, such as + ``invalid_form`` or ``invalid_template_key``, and ``field`` names the + argument when there is one. + """ + + def __init__(self, *, code: str, message: str, field: Optional[str]) -> None: + super().__init__( + name="validation_error", code=code, message=message, field=field + ) + + +def _invalid(field: Optional[str], message: str) -> EmailValidationError: + return EmailValidationError(code="invalid_form", message=message, field=field) + + @dataclass(frozen=True) class EmailWarning: code: str @@ -192,10 +221,9 @@ class EmailWarning: @dataclass(frozen=True) class SendEmailResult: - """The queued email. ``id`` is ``None`` when the client is disabled and sent nothing.""" + """The queued email.""" - id: Optional[str] - replayed: bool = False + id: str warnings: tuple[EmailWarning, ...] = () @@ -228,6 +256,7 @@ class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] idempotency_key: NotRequired[Optional[str]] + max_retries: NotRequired[int] class TemplateEmailArgs(_RecipientArgs): @@ -240,10 +269,15 @@ class TemplateEmailArgs(_RecipientArgs): placeholders. distinct_id: The recipient's distinct ID in your product. idempotency_key: 1 to 255 visible ASCII characters. + max_retries: Retries for this send, instead of the client's. + capture_engagement: Capture opens and clicks. Without it, the + project's setting applies. Setting it needs a PostHog version with + the email send route, ``/messaging/send/email/``. """ template_id: Union[str, UUID] template_data: NotRequired[Optional[Mapping[str, Any]]] + capture_engagement: NotRequired[bool] class CodeEmailArgs(_RecipientArgs): @@ -284,6 +318,7 @@ class CodeEmailArgs(_RecipientArgs): another email, or for a ``template_id`` send, raises ``idempotency_key_reused``. A retry with the same key returns the first send's result, also when its workflow was paused since. + max_retries: Retries for this send, instead of the client's. """ template_key: str @@ -313,58 +348,63 @@ class _PreparedSend: body: bytes idempotency_key: str upload: Optional[_PreparedUpload] = None + max_retries: Optional[int] = None def _prepare(args: Mapping[str, Any]) -> _PreparedSend: is_template_form = "template_id" in args if is_template_form == ("template_key" in args): - raise TypeError("Pass either template_id or template_key.") + raise _invalid(None, "Pass either template_id or template_key.") form = TemplateEmailArgs if is_template_form else CodeEmailArgs unexpected = sorted(set(args) - set(form.__annotations__)) if unexpected: - raise TypeError( + raise _invalid( + unexpected[0], f"send() got arguments that the {'template' if is_template_form else 'code'} " - f"form doesn't take: {', '.join(unexpected)}." + f"form doesn't take: {', '.join(unexpected)}.", ) if "to" not in args: - raise TypeError("send() is missing the argument to.") - if is_template_form: - return _prepare_send( - args["to"], - args["template_id"], - args.get("template_data"), - args.get("distinct_id"), - args.get("idempotency_key"), - ) - return _prepare_code_send(args) + raise _invalid("to", "send() is missing the argument to.") + prepared = _prepare_send(args) if is_template_form else _prepare_code_send(args) + if "max_retries" not in args: + return prepared + if not _is_retry_count(args["max_retries"]): + raise _invalid("max_retries", "max_retries must be a nonnegative integer.") + return replace(prepared, max_retries=args["max_retries"]) + + +def _is_retry_count(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: if "subject" not in args: - raise TypeError("send() is missing the argument subject.") + raise _invalid("subject", "send() is missing the argument subject.") to, distinct_id, idempotency_key = _recipient( args["to"], args.get("distinct_id"), args.get("idempotency_key") ) template_key = args["template_key"] if not isinstance(template_key, str) or not _TEMPLATE_KEY.fullmatch(template_key): - raise ValueError( - "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400." + raise EmailValidationError( + code="invalid_template_key", + message="template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.", + field="template_key", ) content = {"subject": _nonempty_text(args, "subject")} content.update( {field: _string(args, field) for field in ("html", "text") if field in args} ) if "html" not in content and "text" not in content: - raise TypeError("send() needs html, text or both.") + raise _invalid("html", "send() needs html, text or both.") if not (content.get("html") or content.get("text")): - raise ValueError("html or text must be a nonempty string.") + raise _invalid("html", "html or text must be a nonempty string.") payload: dict[str, Any] = {"to": to} upload = None if "template_data" in args: template_data = args["template_data"] if not isinstance(template_data, Mapping): - raise TypeError("template_data must be a mapping.") + raise _invalid("template_data", "template_data must be a mapping.") payload["template"] = {"key": template_key, "hash": _content_hash(content)} payload["message_data"] = clean(dict(template_data)) upload = _PreparedUpload( @@ -414,21 +454,28 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: sender["tags"] = _tags(args["tags"]) if "attachments" in args: sender["attachments"] = _attachments(args["attachments"]) - if "capture_engagement" in args: - if not isinstance(args["capture_engagement"], bool): - raise TypeError("capture_engagement must be True or False.") - sender["capture_engagement"] = args["capture_engagement"] + sender.update(_capture_engagement(args)) return sender +def _capture_engagement(args: Mapping[str, Any]) -> dict[str, bool]: + if "capture_engagement" not in args: + return {} + if not isinstance(args["capture_engagement"], bool): + raise _invalid( + "capture_engagement", "capture_engagement must be True or False." + ) + return {"capture_engagement": args["capture_engagement"]} + + def _addresses(field: str, value: Any) -> list[str]: addresses = [value] if isinstance(value, str) else value if not isinstance(addresses, Sequence) or not all( isinstance(address, str) for address in addresses ): - raise TypeError(f"{field} must be one address or a list of addresses.") + raise _invalid(field, f"{field} must be one address or a list of addresses.") if not all(address.strip() for address in addresses): - raise ValueError(f"{field} must not hold a blank address.") + raise _invalid(field, f"{field} must not hold a blank address.") return list(addresses) @@ -437,7 +484,7 @@ def _headers_field(headers: Any) -> dict[str, str]: isinstance(name, str) and isinstance(value, str) for name, value in headers.items() ): - raise TypeError("headers must map header names to string values.") + raise _invalid("headers", "headers must map header names to string values.") return dict(headers) @@ -447,28 +494,30 @@ def _tags(tags: Any) -> list[dict[str, str]]: or not isinstance(tags, Sequence) or not all(_is_tag(tag) for tag in tags) ): - raise TypeError("tags must be a list of {'name': str, 'value': str}.") + raise _invalid("tags", "tags must be a list of {'name': str, 'value': str}.") return [{"name": tag["name"], "value": tag["value"]} for tag in tags] def _attachments(attachments: Any) -> list[dict[str, str]]: if isinstance(attachments, (str, bytes)) or not isinstance(attachments, Sequence): - raise TypeError("attachments must be a list of files.") + raise _invalid("attachments", "attachments must be a list of files.") return [_attachment(index, file) for index, file in enumerate(attachments)] def _attachment(index: int, file: Any) -> dict[str, str]: name = f"attachments[{index}]" if not isinstance(file, Mapping) or not {"filename", "content"} <= set(file): - raise TypeError(f"{name} needs a filename and its content.") + raise _invalid("attachments", f"{name} needs a filename and its content.") unexpected = set(file) - set(EmailAttachment.__annotations__) if unexpected: - raise TypeError( - f"{name} has fields an attachment doesn't take: {', '.join(sorted(unexpected))}." + raise _invalid( + "attachments", + f"{name} has fields an attachment doesn't take: {', '.join(sorted(unexpected))}.", ) if not all(isinstance(file[field], str) for field in file if field != "content"): - raise TypeError( - f"{name} needs its filename, content_type and content_id as strings." + raise _invalid( + "attachments", + f"{name} needs its filename, content_type and content_id as strings.", ) return {**file, "content": _base64_content(name, file["content"])} @@ -478,9 +527,10 @@ def _base64_content(name: str, content: Any) -> str: return base64.b64encode(bytes(content)).decode("ascii") if isinstance(content, str): return content - raise TypeError( + raise _invalid( + "attachments", f"{name}.content must be bytes or a Base64 string. " - "Convert a list of byte values with bytes(content)." + "Convert a list of byte values with bytes(content).", ) @@ -496,14 +546,14 @@ def _is_tag(tag: Any) -> bool: def _string(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str): - raise TypeError(f"{name} must be a string.") + raise _invalid(name, f"{name} must be a string.") return value def _nonempty_text(args: Mapping[str, Any], name: str) -> str: value = _string(args, name) if not value: - raise ValueError(f"{name} must not be empty.") + raise _invalid(name, f"{name} must not be empty.") return value @@ -513,22 +563,24 @@ def _encode(payload: Mapping[str, Any]) -> bytes: ).encode("utf-8") -def _prepare_send( - to: str, - template_id: Union[str, UUID], - template_data: Optional[Mapping[str, Any]], - distinct_id: Optional[str], - idempotency_key: Optional[str], -) -> _PreparedSend: - to, distinct_id, idempotency_key = _recipient(to, distinct_id, idempotency_key) +def _prepare_send(args: Mapping[str, Any]) -> _PreparedSend: + to, distinct_id, idempotency_key = _recipient( + args["to"], args.get("distinct_id"), args.get("idempotency_key") + ) + template_id = args["template_id"] + template_data = args.get("template_data") try: template_uuid = ( template_id if isinstance(template_id, UUID) else UUID(template_id) ) except (TypeError, ValueError, AttributeError): - raise ValueError("template_id must be the template's UUID.") from None + raise EmailValidationError( + code="invalid_template_id", + message="template_id must be the template's UUID.", + field="template_id", + ) from None if template_data is not None and not isinstance(template_data, Mapping): - raise TypeError("template_data must be a mapping.") + raise _invalid("template_data", "template_data must be a mapping.") payload: dict[str, Any] = { "to": to, @@ -536,6 +588,16 @@ def _prepare_send( } if distinct_id is not None: payload["identifiers"] = {"distinct_id": distinct_id} + tracking = _capture_engagement(args) + if tracking: + # The template route drops fields it doesn't know, so only the email send route can take this one. + return _PreparedSend( + path=_CODE_SEND_PATH, + body=_encode( + {"template": {"id": str(template_uuid)}, **payload, **tracking} + ), + idempotency_key=idempotency_key, + ) return _PreparedSend( path=_SEND_PATH.format(template_id=template_uuid), body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"), @@ -547,25 +609,29 @@ def _recipient( to: Any, distinct_id: Any, idempotency_key: Any ) -> tuple[str, Optional[str], str]: if not isinstance(to, str): - raise TypeError("to must be one email address as a string.") + raise _invalid("to", "to must be one email address as a string.") if not to.strip(): - raise ValueError("to must not be empty.") + raise _invalid("to", "to must not be empty.") if distinct_id is not None and ( not isinstance(distinct_id, str) or not distinct_id ): - raise ValueError("distinct_id must be a nonempty string.") + raise _invalid("distinct_id", "distinct_id must be a nonempty string.") if idempotency_key is None: idempotency_key = str(uuid4()) - elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.match( + elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch( idempotency_key ): - raise ValueError( - "idempotency_key must be 1 to 255 visible ASCII characters without spaces." + raise EmailValidationError( + code="invalid_idempotency_key", + message="idempotency_key must be 1 to 255 visible ASCII characters without spaces.", + field="idempotency_key", ) return to, distinct_id, idempotency_key -def _validate_options(timeout: float, max_retries: int) -> None: +def _validate_options( + timeout: float, max_retries: int, migrated_from: Optional[str] +) -> None: if ( isinstance(timeout, bool) or not isinstance(timeout, (int, float)) @@ -573,12 +639,12 @@ def _validate_options(timeout: float, max_retries: int) -> None: or timeout <= 0 ): raise ValueError("timeout must be a finite positive number of seconds.") - if ( - isinstance(max_retries, bool) - or not isinstance(max_retries, int) - or max_retries < 0 - ): + if not _is_retry_count(max_retries): raise ValueError("max_retries must be a nonnegative integer.") + if migrated_from is not None and not ( + isinstance(migrated_from, str) and migrated_from + ): + raise ValueError("migrated_from must be a nonempty string, such as 'resend'.") def _text(value: Any) -> Optional[str]: @@ -639,7 +705,6 @@ def _parse_response( warnings = fields.get("warnings") return SendEmailResult( id=message_id, - replayed=fields.get("replayed") is True, warnings=tuple( EmailWarning( code=_text(warning.get("code")) or "warning", @@ -692,11 +757,8 @@ def _is_retryable(error: EmailError) -> bool: def _retry_delay(error: EmailError, attempt: int, max_retries: int) -> Optional[float]: if attempt >= max_retries or not _is_retryable(error): return None - # Retrying before a longer Retry-After would break the server's rate limit. - if error.retry_after is not None and error.retry_after > _MAX_RETRY_DELAY: - return None - backoff = min(_MAX_RETRY_DELAY, 2.0**attempt * random.uniform(0.8, 1.2)) - return max(backoff, error.retry_after or 0.0) + backoff = 2.0**attempt * random.uniform(0.8, 1.2) + return min(_MAX_RETRY_DELAY, max(backoff, error.retry_after or 0.0)) def _no_op() -> None: @@ -712,7 +774,9 @@ def _log_warnings(result: SendEmailResult) -> None: log.warning("PostHog email warning %s: %s", warning.code, warning.message) -def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: +def _headers( + secret_key: str, idempotency_key: Optional[str], migrated_from: Optional[str] +) -> dict[str, str]: headers = { "Authorization": f"Bearer {secret_key}", "Accept": "application/json", @@ -721,6 +785,8 @@ def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: } if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key + if migrated_from is not None: + headers["X-PostHog-Migrated-From"] = migrated_from return headers @@ -729,8 +795,13 @@ def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: class _BearerAuth(requests.auth.AuthBase): - def __init__(self, secret_key: str, idempotency_key: Optional[str]) -> None: - self._headers = _headers(secret_key, idempotency_key) + def __init__( + self, + secret_key: str, + idempotency_key: Optional[str], + migrated_from: Optional[str], + ) -> None: + self._headers = _headers(secret_key, idempotency_key, migrated_from) def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: # Setting auth explicitly stops requests from substituting .netrc credentials. @@ -747,13 +818,15 @@ def __init__( timeout: float, max_retries: int, disabled: bool, + migrated_from: Optional[str], ) -> None: - _validate_options(timeout, max_retries) + _validate_options(timeout, max_retries, migrated_from) self.secret_key = secret_key.strip() if isinstance(secret_key, str) else None self.host = determine_app_host(host) self.timeout = timeout self.max_retries = max_retries self.disabled = disabled + self.migrated_from = migrated_from self._parent_disabled: Callable[[], bool] = lambda: False self._admit: Callable[[], Optional[Callable[[], None]]] = lambda: _no_op self._host_error: Optional[str] = None @@ -762,6 +835,11 @@ def _is_disabled(self) -> bool: return self.disabled or self._parent_disabled() def _start_send(self) -> Callable[[], None]: + if self._is_disabled(): + raise _sdk_error( + "client_disabled", + "The PostHog client is disabled, so no email was sent.", + ) finish = self._admit() if finish is None: raise _sdk_error( @@ -770,9 +848,14 @@ def _start_send(self) -> Callable[[], None]: ) return finish + def _retries(self, prepared: _PreparedSend) -> int: + return ( + self.max_retries if prepared.max_retries is None else prepared.max_retries + ) + def _secret_key(self) -> str: if self._host_error: - raise _sdk_error("app_host_required", self._host_error) + raise _sdk_error("missing_app_host", self._host_error) if not self.secret_key: raise _sdk_error( "missing_secret_key", @@ -792,7 +875,8 @@ class Emails(_EmailsBase): to ``max_retries`` times, with the same idempotency key. Set ``max_retries=0`` when a job runner already retries the send. ``timeout`` is in seconds, per request. Pass ``session`` to send through your own - ``requests.Session``, for example a test double. + ``requests.Session``, for example a test double. ``migrated_from``, such + as ``"resend"``, tells PostHog which provider your sends came from. """ def __init__( @@ -804,6 +888,7 @@ def __init__( max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None, + migrated_from: Optional[str] = None, ) -> None: super().__init__( secret_key, @@ -811,6 +896,7 @@ def __init__( timeout=timeout, max_retries=max_retries, disabled=disabled, + migrated_from=migrated_from, ) self._session: Optional[requests.Session] = session self._owns_session = session is None @@ -845,6 +931,10 @@ def send(self, **kwargs: Any) -> SendEmailResult: that covers its own retries. Pass a key derived from what the email is for, such as ``welcome:user_123``, to cover retries outside this call, too. + max_retries: Retries for this send, instead of the client's. + ``0`` turns them off. + capture_engagement: Capture opens and clicks, instead of the + project's setting. Returns: The queued email. A result means PostHog queued the email, not that @@ -853,7 +943,8 @@ def send(self, **kwargs: Any) -> SendEmailResult: Raises: EmailError: PostHog did not queue the email, or the client could not tell whether it did. - TypeError, ValueError: An argument has the wrong type or format. + EmailValidationError: An argument has the wrong type or format. + Nothing was sent. Examples: ```python @@ -874,9 +965,6 @@ def send(self, **kwargs: Any) -> SendEmailResult: ``` """ prepared = _prepare(kwargs) - if self._is_disabled(): - log.debug("PostHog client is disabled, so no email was sent.") - return SendEmailResult(id=None) finish = self._start_send() try: return self._deliver(prepared) @@ -885,10 +973,11 @@ def send(self, **kwargs: Any) -> SendEmailResult: def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: secret_key = self._secret_key() - auth = _BearerAuth(secret_key, prepared.idempotency_key) + retries = self._retries(prepared) + auth = _BearerAuth(secret_key, prepared.idempotency_key, self.migrated_from) try: result = self._request( - "POST", prepared.path, prepared.body, auth, _parse_response + "POST", prepared.path, prepared.body, auth, retries, _parse_response ) except EmailError as error: if prepared.upload is None or not _is_outdated(error): @@ -897,11 +986,12 @@ def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: "PUT", prepared.upload.path, prepared.upload.body, - _BearerAuth(secret_key, None), + _BearerAuth(secret_key, None, self.migrated_from), + retries, _parse_upload, ) result = self._request( - "POST", prepared.path, prepared.body, auth, _parse_response + "POST", prepared.path, prepared.body, auth, retries, _parse_response ) _log_warnings(result) return result @@ -912,11 +1002,12 @@ def _request( path: str, body: bytes, auth: _BearerAuth, + max_retries: int, parse: _Parse[_ResultT], ) -> _ResultT: session = self._get_session() url = self.host + path - for attempt in range(self.max_retries + 1): + for attempt in range(max_retries + 1): try: response = session.request( method, @@ -942,7 +1033,7 @@ def _request( error = _sdk_error("network_error", "The request to PostHog failed.") except EmailError as exc: error = exc - delay = _retry_delay(error, attempt, self.max_retries) + delay = _retry_delay(error, attempt, max_retries) if delay is None: raise error from None time.sleep(delay) @@ -992,6 +1083,7 @@ def __init__( max_retries: int = 2, disabled: bool = False, http_client: Any = None, + migrated_from: Optional[str] = None, ) -> None: super().__init__( secret_key, @@ -999,6 +1091,7 @@ def __init__( timeout=timeout, max_retries=max_retries, disabled=disabled, + migrated_from=migrated_from, ) self._client: Any = http_client self._owns_client = http_client is None @@ -1026,9 +1119,6 @@ async def send(self, **kwargs: Any) -> SendEmailResult: ``` """ prepared = _prepare(kwargs) - if self._is_disabled(): - log.debug("PostHog client is disabled, so no email was sent.") - return SendEmailResult(id=None) finish = self._start_send() try: return await self._deliver(prepared) @@ -1037,10 +1127,11 @@ async def send(self, **kwargs: Any) -> SendEmailResult: async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: secret_key = self._secret_key() - headers = _headers(secret_key, prepared.idempotency_key) + retries = self._retries(prepared) + headers = _headers(secret_key, prepared.idempotency_key, self.migrated_from) try: result = await self._request( - "POST", prepared.path, prepared.body, headers, _parse_response + "POST", prepared.path, prepared.body, headers, retries, _parse_response ) except EmailError as error: if prepared.upload is None or not _is_outdated(error): @@ -1049,11 +1140,12 @@ async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: "PUT", prepared.upload.path, prepared.upload.body, - _headers(secret_key, None), + _headers(secret_key, None, self.migrated_from), + retries, _parse_upload, ) result = await self._request( - "POST", prepared.path, prepared.body, headers, _parse_response + "POST", prepared.path, prepared.body, headers, retries, _parse_response ) _log_warnings(result) return result @@ -1064,11 +1156,12 @@ async def _request( path: str, body: bytes, headers: Mapping[str, str], + max_retries: int, parse: _Parse[_ResultT], ) -> _ResultT: httpx, client = self._get_client() url = self.host + path - for attempt in range(self.max_retries + 1): + for attempt in range(max_retries + 1): try: response = await client.request( method, url, content=body, headers=headers @@ -1089,7 +1182,7 @@ async def _request( error = _sdk_error("network_error", "The request to PostHog failed.") except EmailError as exc: error = exc - delay = _retry_delay(error, attempt, self.max_retries) + delay = _retry_delay(error, attempt, max_retries) if delay is None: raise error from None await asyncio.sleep(delay) @@ -1139,10 +1232,15 @@ def _client_emails( app_host: Optional[str], timeout: float, max_retries: int, + migrated_from: Optional[str], ) -> _EmailsT: if app_host: emails = factory( - secret_key, host=app_host, timeout=timeout, max_retries=max_retries + secret_key, + host=app_host, + timeout=timeout, + max_retries=max_retries, + migrated_from=migrated_from, ) emails._parent_disabled = is_disabled emails._admit = admit @@ -1154,6 +1252,7 @@ def _client_emails( host=inferred_host if is_cloud else None, timeout=timeout, max_retries=max_retries, + migrated_from=migrated_from, ) if not is_cloud: # A custom host is often an ingestion proxy, which must not receive the secret key. diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index b7bb8167c..f37443830 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -14,7 +14,7 @@ from parameterized import parameterized from posthog import AsyncPosthog, Posthog -from posthog.emails import AsyncEmails, EmailError, Emails +from posthog.emails import AsyncEmails, EmailError, Emails, EmailValidationError from posthog.version import VERSION SECRET_KEY = "phs_test_secret" @@ -114,12 +114,12 @@ def test_send_posts_the_template_to_the_app_host(_name, overrides, extra_body): ) assert result.id == "msg_1" - assert result.replayed is False [request] = adapter.requests assert (request.method, request.url) == ("POST", SEND_URL) assert request.headers["Authorization"] == f"Bearer {SECRET_KEY}" assert request.headers["Idempotency-Key"] == "welcome:user_123" assert request.headers["User-Agent"] == f"posthog-python/{VERSION}" + assert "X-PostHog-Migrated-From" not in request.headers assert json.loads(request.body) == { "to": "user@example.com", "message_data": {"total": 19.5, "expires_at": "2026-11-20T00:00:00+00:00"}, @@ -127,6 +127,33 @@ def test_send_posts_the_template_to_the_app_host(_name, overrides, extra_body): } +def test_a_template_send_that_sets_capture_engagement_uses_the_email_send_route(): + emails, adapter = emails_with([accepted("msg_1")]) + + result = send( + emails, + template_data={"name": "Ada"}, + distinct_id="user_123", + idempotency_key="welcome:user_123", + capture_engagement=False, + ) + + assert result.id == "msg_1" + [request] = adapter.requests + assert (request.method, request.url) == ( + "POST", + "https://us.posthog.com/api/projects/@current/messaging/send/email/", + ) + assert request.headers["Idempotency-Key"] == "welcome:user_123" + assert json.loads(request.body) == { + "to": "user@example.com", + "template": {"id": TEMPLATE_ID}, + "message_data": {"name": "Ada"}, + "identifiers": {"distinct_id": "user_123"}, + "capture_engagement": False, + } + + @parameterized.expand( [ ("default", None, "https://us.posthog.com"), @@ -158,7 +185,7 @@ def test_a_client_with_a_custom_host_never_sends_it_the_secret_key(): with pytest.raises(EmailError) as raised: send(client.emails) - assert raised.value.code == "app_host_required" + assert raised.value.code == "missing_app_host" request.assert_not_called() @@ -171,6 +198,7 @@ def test_client_email_options_set_the_host_timeout_and_retries(): app_host="https://posthog.example.com/", email_request_timeout_seconds=3, email_request_max_retries=0, + migrated_from="resend", ) unavailable = response(503, {"code": "delivery_unavailable"}) @@ -183,6 +211,10 @@ def test_client_email_options_set_the_host_timeout_and_retries(): request.assert_called_once() assert request.call_args.args[1].startswith("https://posthog.example.com/api/") assert request.call_args.kwargs["timeout"] == 3 + sent = request.call_args.kwargs["auth"]( + requests.Request("POST", "https://x").prepare() + ) + assert sent.headers["X-PostHog-Migrated-From"] == "resend" @parameterized.expand( @@ -254,20 +286,16 @@ def test_network_failure_after_retries_is_service_unavailable(): ) -def test_waits_for_retry_after_and_gives_up_on_a_longer_one(no_sleep): +@pytest.mark.parametrize(("retry_after", "waited"), [("5", 5.0), ("120", 30.0)]) +def test_waits_for_retry_after_up_to_30_seconds(retry_after, waited, no_sleep): emails, adapter = emails_with( - [rejected(429, "throttled", **{"Retry-After": "5"}), accepted()] + [rejected(429, "throttled", **{"Retry-After": retry_after}), accepted()] ) - send(emails) - no_sleep.assert_called_once_with(5.0) - emails, adapter = emails_with( - [rejected(429, "throttled", **{"Retry-After": "120"})] - ) - with pytest.raises(EmailError) as raised: - send(emails) - assert raised.value.retry_after == 120.0 - assert len(adapter.requests) == 1 + assert send(emails).id == "msg_1" + + no_sleep.assert_called_once_with(waited) + assert len(adapter.requests) == 2 def test_max_retries_zero_leaves_retries_to_the_caller(): @@ -281,6 +309,29 @@ def test_max_retries_zero_leaves_retries_to_the_caller(): assert len(adapter.requests) == 1 +@parameterized.expand( + [ + ("turns_retries_off", 2, 0, 1), + ("turns_retries_on", 0, 1, 2), + ] +) +def test_a_send_overrides_the_clients_max_retries( + _name, client_max_retries, send_max_retries, requests_made +): + emails, adapter = emails_with( + [rejected(503, "delivery_unavailable"), accepted()], + max_retries=client_max_retries, + ) + + try: + send(emails, max_retries=send_max_retries) + except EmailError: + pass + + assert len(adapter.requests) == requests_made + assert "max_retries" not in json.loads(adapter.requests[0].body) + + def test_an_acceptance_without_a_message_id_is_not_retried(): emails, adapter = emails_with([(202, {}, {}), accepted()]) @@ -291,18 +342,17 @@ def test_an_acceptance_without_a_message_id_is_not_retried(): assert len(adapter.requests) == 1 -def test_replays_and_warnings_reach_the_caller(caplog): +def test_warnings_reach_the_caller(caplog): warning = { "code": "template_superseded", "detail": "Newer content.", "fix": "Upload.", } - emails, _ = emails_with([accepted("msg_1", replayed=True, warnings=[warning])]) + emails, _ = emails_with([accepted("msg_1", warnings=[warning])]) with caplog.at_level(logging.WARNING, logger="posthog"): result = send(emails) - assert result.replayed is True assert [(w.code, w.fix) for w in result.warnings] == [ ("template_superseded", "Upload.") ] @@ -323,15 +373,19 @@ def test_missing_secret_key_fails_before_sending(): request.assert_not_called() -def test_a_disabled_client_sends_nothing(): +def test_a_disabled_client_refuses_the_send(): client = Posthog("phc_test", secret_key=SECRET_KEY, sync_mode=True) emails = client.emails client.disabled = True with mock.patch.object(requests.Session, "request") as request: - result = send(emails) + with pytest.raises(EmailError) as raised: + send(emails) - assert result.id is None + assert (raised.value.code, raised.value.name) == ( + "client_disabled", + "application_error", + ) request.assert_not_called() @@ -352,23 +406,70 @@ def test_a_shut_down_client_sends_no_email(): @parameterized.expand( [ - ("several_recipients", {"to": ["a@example.com", "b@example.com"]}, TypeError), - ("empty_recipient", {"to": " "}, ValueError), - ("template_name", {"template_id": "welcome"}, ValueError), - ("template_data_list", {"template_data": [1, 2]}, TypeError), - ("key_with_space", {"idempotency_key": "welcome user"}, ValueError), - ("key_too_long", {"idempotency_key": "k" * 256}, ValueError), + ("several_recipients", {"to": ["a@example.com"]}, "invalid_form", "to"), + ("empty_recipient", {"to": " "}, "invalid_form", "to"), + ( + "template_name", + {"template_id": "welcome"}, + "invalid_template_id", + "template_id", + ), + ("negative_max_retries", {"max_retries": -1}, "invalid_form", "max_retries"), + ( + "capture_engagement_not_bool", + {"capture_engagement": "no"}, + "invalid_form", + "capture_engagement", + ), + ("max_retries_not_int", {"max_retries": True}, "invalid_form", "max_retries"), + ( + "template_data_list", + {"template_data": [1, 2]}, + "invalid_form", + "template_data", + ), + ( + "key_with_space", + {"idempotency_key": "a b"}, + "invalid_idempotency_key", + "idempotency_key", + ), + ( + "key_with_trailing_newline", + {"idempotency_key": "a\n"}, + "invalid_idempotency_key", + "idempotency_key", + ), + ( + "key_too_long", + {"idempotency_key": "k" * 256}, + "invalid_idempotency_key", + "idempotency_key", + ), ] ) -def test_rejects_invalid_arguments_before_sending(_name, overrides, error): +def test_rejects_invalid_arguments_before_sending(_name, overrides, code, field): emails, adapter = emails_with([]) - with pytest.raises(error): + with pytest.raises(EmailValidationError) as raised: send(emails, **overrides) + assert_validation_error(raised.value, code, field) assert adapter.requests == [] +def assert_validation_error( + error: EmailValidationError, code: str, field: Optional[str] +) -> None: + assert isinstance(error, ValueError) + assert (error.name, error.code, error.field, error.status_code) == ( + "validation_error", + code, + field, + None, + ) + + @pytest.mark.asyncio async def test_async_send_retries_with_the_same_key(): seen: list[httpx.Request] = [] @@ -400,7 +501,7 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_async_disabled_client_sends_nothing(): +async def test_async_disabled_client_refuses_the_send(): handler = mock.Mock(side_effect=AssertionError("a disabled client sent a request")) emails = AsyncEmails( SECRET_KEY, @@ -408,9 +509,10 @@ async def test_async_disabled_client_sends_nothing(): http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), ) - result = await emails.send(to="user@example.com", template_id=TEMPLATE_ID) + with pytest.raises(EmailError) as raised: + await emails.send(to="user@example.com", template_id=TEMPLATE_ID) - assert result.id is None + assert raised.value.code == "client_disabled" handler.assert_not_called() @@ -600,6 +702,23 @@ def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_ke ) +def test_every_request_of_a_send_says_where_the_caller_migrated_from(): + emails, adapter = emails_with( + [rejected(409, "template_outdated"), uploaded(), accepted()], + migrated_from="resend", + ) + + send_local_template(emails) + + assert [ + request.headers["X-PostHog-Migrated-From"] for request in adapter.requests + ] == [ + "resend", + "resend", + "resend", + ] + + @parameterized.expand( [ ("refused_upload", rejected(409, "template_edited_in_posthog"), 2), @@ -650,9 +769,9 @@ def handler(request: httpx.Request) -> httpx.Response: return replies.pop(0) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: - result = await AsyncEmails(SECRET_KEY, http_client=client).send( - to="user@example.com", **WELCOME, template_data={"first_name": "Jane"} - ) + result = await AsyncEmails( + SECRET_KEY, http_client=client, migrated_from="resend" + ).send(to="user@example.com", **WELCOME, template_data={"first_name": "Jane"}) assert result.id == "msg_1" assert [(request.method, str(request.url)) for request in seen] == [ @@ -661,6 +780,9 @@ def handler(request: httpx.Request) -> httpx.Response: ("POST", CODE_SEND_URL), ] first_send, upload, second_send = seen + assert {request.headers["X-PostHog-Migrated-From"] for request in seen} == { + "resend" + } assert json.loads(first_send.content)["template"] == { "key": "welcome", "hash": WELCOME_HASH, @@ -674,29 +796,51 @@ def handler(request: httpx.Request) -> httpx.Response: @parameterized.expand( [ - ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError), - ("no_form", {"template_key": None}, TypeError), - ("no_content", {}, TypeError), - ("no_subject", {"subject": None, "html": HTML}, TypeError), - ("subject_not_text", {"subject": 1, "html": HTML}, TypeError), - ("from_not_text", {"html": HTML, "from_": 1}, TypeError), - ("from_keyword", {"html": HTML, "from": "a@example.com"}, TypeError), + ( + "both_forms", + {"template_id": TEMPLATE_ID, "html": HTML}, + "invalid_form", + None, + ), + ("no_form", {"template_key": None}, "invalid_form", None), + ("no_content", {}, "invalid_form", "html"), + ("no_subject", {"subject": None, "html": HTML}, "invalid_form", "subject"), + ("subject_not_text", {"subject": 1, "html": HTML}, "invalid_form", "subject"), + ("from_not_text", {"html": HTML, "from_": 1}, "invalid_form", "from_"), + ( + "from_keyword", + {"html": HTML, "from": "a@example.com"}, + "invalid_form", + "from", + ), ( "key_with_space", - {"template_key": "reset password", "html": HTML}, - ValueError, + {"template_key": "a b", "html": HTML}, + "invalid_template_key", + "template_key", ), ( "key_with_trailing_newline", - {"template_key": "welcome\n", "html": HTML}, - ValueError, + {"template_key": "a\n", "html": HTML}, + "invalid_template_key", + "template_key", + ), + ( + "key_too_long", + {"template_key": "k" * 401, "html": HTML}, + "invalid_template_key", + "template_key", + ), + ("empty_html", {"html": ""}, "invalid_form", "html"), + ("empty_html_and_text", {"html": "", "text": ""}, "invalid_form", "html"), + ("blank_cc", {"html": HTML, "cc": " "}, "invalid_form", "cc"), + ("cc_not_text", {"html": HTML, "cc": 123}, "invalid_form", "cc"), + ( + "template_data_list", + {"html": HTML, "template_data": [1]}, + "invalid_form", + "template_data", ), - ("key_too_long", {"template_key": "k" * 401, "html": HTML}, ValueError), - ("empty_html", {"html": ""}, ValueError), - ("empty_html_and_text", {"html": "", "text": ""}, ValueError), - ("blank_cc", {"html": HTML, "cc": " "}, ValueError), - ("cc_not_text", {"html": HTML, "cc": 123}, TypeError), - ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), ( "cc_on_template_form", { @@ -705,11 +849,12 @@ def handler(request: httpx.Request) -> httpx.Response: "subject": None, "cc": "a@example.com", }, - TypeError, + "invalid_form", + "cc", ), ] ) -def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error): +def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, code, field): emails, adapter = emails_with([]) args = { "to": "user@example.com", @@ -718,11 +863,12 @@ def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error **overrides, } - with pytest.raises(error): + with pytest.raises(EmailValidationError) as raised: emails.send( **{name: value for name, value in args.items() if value is not None} ) + assert_validation_error(raised.value, code, field) assert adapter.requests == [] @@ -858,8 +1004,9 @@ def test_attachment_content_from_any_bytes_buffer_is_base64_encoded(_name, conte def test_rejects_an_attachment_that_is_not_a_file(_name, attachment): emails, adapter = emails_with([]) - with pytest.raises(TypeError) as raised: + with pytest.raises(EmailValidationError) as raised: send_code(emails, html=HTML, attachments=[attachment]) + assert_validation_error(raised.value, "invalid_form", "attachments") assert "104" not in str(raised.value) assert adapter.requests == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index c1fd1b560..421b49ece 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -692,6 +692,7 @@ attribute posthog.async_client.AsyncClient.is_server = is_server attribute posthog.async_client.AsyncClient.log = logging.getLogger('posthog') attribute posthog.async_client.AsyncClient.log_captured_exceptions = log_captured_exceptions attribute posthog.async_client.AsyncClient.max_retries = max(0, max_retries) +attribute posthog.async_client.AsyncClient.migrated_from = migrated_from attribute posthog.async_client.AsyncClient.on_error = on_error attribute posthog.async_client.AsyncClient.personal_api_key = self.secret_key attribute posthog.async_client.AsyncClient.project_root = os.getcwd() @@ -762,6 +763,7 @@ attribute posthog.client.Client.log = logging.getLogger('posthog') attribute posthog.client.Client.log_captured_exceptions = log_captured_exceptions attribute posthog.client.Client.max_retries = max(0, max_retries) attribute posthog.client.Client.metrics: PostHogMetrics +attribute posthog.client.Client.migrated_from = migrated_from attribute posthog.client.Client.on_error = on_error attribute posthog.client.Client.personal_api_key = self.secret_key attribute posthog.client.Client.poll_interval = poll_interval @@ -849,9 +851,9 @@ attribute posthog.emails.EmailTag.value: str attribute posthog.emails.EmailWarning.code: str attribute posthog.emails.EmailWarning.fix: Optional[str] = None attribute posthog.emails.EmailWarning.message: str -attribute posthog.emails.SendEmailResult.id: Optional[str] -attribute posthog.emails.SendEmailResult.replayed: bool = False +attribute posthog.emails.SendEmailResult.id: str attribute posthog.emails.SendEmailResult.warnings: tuple[EmailWarning, ...] = () +attribute posthog.emails.TemplateEmailArgs.capture_engagement: NotRequired[bool] attribute posthog.emails.TemplateEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]] attribute posthog.emails.TemplateEmailArgs.template_id: Union[str, UUID] attribute posthog.enable_exception_autocapture = False @@ -1250,23 +1252,24 @@ class posthog.ai.types.TokenUsage class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs -class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 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) +class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 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, migrated_from: Optional[str] = None) class posthog.async_client.AsyncPosthog class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) class posthog.capture_compression.CaptureCompression class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _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) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _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, migrated_from: Optional[str] = None) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) -class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None) +class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None, migrated_from: Optional[str] = None) class posthog.emails.CodeEmailArgs class posthog.emails.EmailAttachment class posthog.emails.EmailError(*, name: str, code: str, message: str, status_code: Optional[int] = None, why: Optional[str] = None, fix: Optional[str] = None, docs_url: Optional[str] = None, field: Optional[str] = None, retry_after: Optional[float] = None) class posthog.emails.EmailTag +class posthog.emails.EmailValidationError(*, code: str, message: str, field: Optional[str]) class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None) -class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None) -class posthog.emails.SendEmailResult(id: Optional[str], replayed: bool = False, warnings: tuple[EmailWarning, ...] = ()) +class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None, migrated_from: Optional[str] = None) +class posthog.emails.SendEmailResult(id: str, warnings: tuple[EmailWarning, ...] = ()) class posthog.emails.TemplateEmailArgs class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) class posthog.exception_utils.AnnotatedValue(value, metadata) From 1eae15681e574f4293bd451a278529e72ac2ba09 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:07:20 +0000 Subject: [PATCH 10/11] fix: check migrated_from as a header value and pin per-send retries A migrated_from with a line break passed construction, then failed the sync send with a bare ValueError. It must now be visible ASCII. The not_found and idempotency_key docs name the cases a template send with capture_engagement and a reused key run into. Tests now cover a send's max_retries on the upload and the async send, assert the outcome of an override, and read the migrated header from the prepared request. Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 19 +++++--- posthog/test/test_emails.py | 86 ++++++++++++++++++++++++++----------- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index e4f022b5d..fb0f45966 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -76,7 +76,8 @@ - ``authentication_failed`` (401) or ``permission_denied`` (403): the key is wrong, or it lacks ``hog_flow:write``. - ``not_found`` (404): the template ID is wrong, or transactional email is not - enabled for the project. + enabled for the project. A template send with ``capture_engagement`` also + gets it from a PostHog version without the email send route. - ``client_disabled``: the client is disabled. - ``client_closed``: the client is shut down. @@ -157,6 +158,7 @@ _CONTENT_HASH_HEADER = b"posthog-email-template-v1\n" _MAX_RETRY_DELAY = 30.0 _IDEMPOTENCY_KEY = re.compile(r"[\x21-\x7e]{1,255}") +_HEADER_TOKEN = re.compile(r"[\x21-\x7e]+") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) _QUOTA_CODES = frozenset({"quota_limited"}) @@ -642,9 +644,11 @@ def _validate_options( if not _is_retry_count(max_retries): raise ValueError("max_retries must be a nonnegative integer.") if migrated_from is not None and not ( - isinstance(migrated_from, str) and migrated_from + isinstance(migrated_from, str) and _HEADER_TOKEN.fullmatch(migrated_from) ): - raise ValueError("migrated_from must be a nonempty string, such as 'resend'.") + raise ValueError( + "migrated_from must be visible ASCII characters, such as 'resend'." + ) def _text(value: Any) -> Optional[str]: @@ -927,10 +931,11 @@ def send(self, **kwargs: Any) -> SendEmailResult: person whose distinct ID is the email address. idempotency_key: 1 to 255 visible ASCII characters. A send with a key that PostHog has already accepted queues nothing and returns - the first send's result. Without it, the client makes a key - that covers its own retries. Pass a key derived from what the - email is for, such as ``welcome:user_123``, to cover retries - outside this call, too. + the first send's result, when the other arguments are the same. + With other arguments, it raises ``idempotency_key_reused``. + Without it, the client makes a key that covers its own retries. + Pass a key derived from what the email is for, such as + ``welcome:user_123``, to cover retries outside this call, too. max_retries: Retries for this send, instead of the client's. ``0`` turns them off. capture_engagement: Capture opens and clicks, instead of the diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index f37443830..e5dc53f13 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -68,13 +68,6 @@ def emails_with(replies: list[Reply], **options: Any) -> tuple[Emails, FakeAdapt return Emails(SECRET_KEY, session=session, **options), adapter -def response(status: int, body: dict[str, Any]) -> requests.Response: - reply = requests.Response() - reply.status_code = status - reply._content = json.dumps(body).encode() - return reply - - def send(emails: Emails, **overrides: Any): return emails.send( **{"to": "user@example.com", "template_id": TEMPLATE_ID, **overrides} @@ -200,21 +193,27 @@ def test_client_email_options_set_the_host_timeout_and_retries(): email_request_max_retries=0, migrated_from="resend", ) - unavailable = response(503, {"code": "delivery_unavailable"}) + adapter = FakeAdapter([rejected(503, "delivery_unavailable")]) with mock.patch.object( - requests.Session, "request", return_value=unavailable - ) as request: + requests.adapters.HTTPAdapter, + "send", + autospec=True, + side_effect=lambda _transport, request, **_: adapter.send(request), + ) as transport: with pytest.raises(EmailError): send(client.emails) - request.assert_called_once() - assert request.call_args.args[1].startswith("https://posthog.example.com/api/") - assert request.call_args.kwargs["timeout"] == 3 - sent = request.call_args.kwargs["auth"]( - requests.Request("POST", "https://x").prepare() - ) - assert sent.headers["X-PostHog-Migrated-From"] == "resend" + [request] = adapter.requests + assert (request.url or "").startswith("https://posthog.example.com/api/") + assert transport.call_args.kwargs["timeout"] == 3 + assert request.headers["X-PostHog-Migrated-From"] == "resend" + + +@parameterized.expand([("empty", ""), ("line_break", "resend\n"), ("not_a_string", 1)]) +def test_rejects_a_migrated_from_that_is_not_a_header_value(_name, migrated_from): + with pytest.raises(ValueError): + Emails(SECRET_KEY, migrated_from=migrated_from) @parameterized.expand( @@ -311,24 +310,26 @@ def test_max_retries_zero_leaves_retries_to_the_caller(): @parameterized.expand( [ - ("turns_retries_off", 2, 0, 1), - ("turns_retries_on", 0, 1, 2), + ("turns_retries_off", 2, 0, False), + ("turns_retries_on", 0, 1, True), ] ) def test_a_send_overrides_the_clients_max_retries( - _name, client_max_retries, send_max_retries, requests_made + _name, client_max_retries, send_max_retries, retried ): emails, adapter = emails_with( [rejected(503, "delivery_unavailable"), accepted()], max_retries=client_max_retries, ) - try: - send(emails, max_retries=send_max_retries) - except EmailError: - pass - - assert len(adapter.requests) == requests_made + if retried: + assert send(emails, max_retries=send_max_retries).id == "msg_1" + assert len(adapter.requests) == 2 + else: + with pytest.raises(EmailError) as raised: + send(emails, max_retries=send_max_retries) + assert raised.value.code == "delivery_unavailable" + assert len(adapter.requests) == 1 assert "max_retries" not in json.loads(adapter.requests[0].body) @@ -500,6 +501,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert json.loads(seen[0].content)["identifiers"] == {"distinct_id": "user_123"} +@pytest.mark.asyncio +async def test_async_send_overrides_the_clients_max_retries(): + handler = mock.Mock( + return_value=httpx.Response(503, json={"code": "delivery_unavailable"}) + ) + emails = AsyncEmails( + SECRET_KEY, + max_retries=2, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + with mock.patch("posthog.emails.asyncio.sleep"): + with pytest.raises(EmailError): + await emails.send( + to="user@example.com", template_id=TEMPLATE_ID, max_retries=0 + ) + + handler.assert_called_once() + + @pytest.mark.asyncio async def test_async_disabled_client_refuses_the_send(): handler = mock.Mock(side_effect=AssertionError("a disabled client sent a request")) @@ -702,6 +723,19 @@ def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_ke ) +def test_a_sends_max_retries_also_covers_its_upload(): + emails, adapter = emails_with( + [rejected(409, "template_outdated"), rejected(503, "delivery_unavailable")], + max_retries=2, + ) + + with pytest.raises(EmailError) as raised: + send_local_template(emails, max_retries=0) + + assert raised.value.status_code == 503 + assert [request.method for request in adapter.requests] == ["POST", "PUT"] + + def test_every_request_of_a_send_says_where_the_caller_migrated_from(): emails, adapter = emails_with( [rejected(409, "template_outdated"), uploaded(), accepted()], From 97abc5d027e4d58837c3522ad93a9366193a35d9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:16:25 +0000 Subject: [PATCH 11/11] fix: name template_not_found and prove a send's retries reach its upload On the email send route a wrong template ID answers template_not_found, so the not_found entry now says so for a template send with capture_engagement. The upload test now turns retries on for one send over a client with none, so it fails when the upload ignores them. Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 5 +++-- posthog/test/test_emails.py | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index fb0f45966..ad292ccb1 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -76,8 +76,9 @@ - ``authentication_failed`` (401) or ``permission_denied`` (403): the key is wrong, or it lacks ``hog_flow:write``. - ``not_found`` (404): the template ID is wrong, or transactional email is not - enabled for the project. A template send with ``capture_engagement`` also - gets it from a PostHog version without the email send route. + enabled for the project. A template send with ``capture_engagement`` gets + ``template_not_found`` for a wrong ID instead, and ``not_found`` from a + PostHog version without the email send route. - ``client_disabled``: the client is disabled. - ``client_closed``: the client is shut down. diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index e5dc53f13..4ca2c145c 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -725,15 +725,22 @@ def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_ke def test_a_sends_max_retries_also_covers_its_upload(): emails, adapter = emails_with( - [rejected(409, "template_outdated"), rejected(503, "delivery_unavailable")], - max_retries=2, + [ + rejected(409, "template_outdated"), + rejected(503, "delivery_unavailable"), + uploaded(), + accepted(), + ], + max_retries=0, ) - with pytest.raises(EmailError) as raised: - send_local_template(emails, max_retries=0) - - assert raised.value.status_code == 503 - assert [request.method for request in adapter.requests] == ["POST", "PUT"] + assert send_local_template(emails, max_retries=1).id == "msg_1" + assert [request.method for request in adapter.requests] == [ + "POST", + "PUT", + "PUT", + "POST", + ] def test_every_request_of_a_send_says_where_the_caller_migrated_from():