From 625f373acb6a51639fcc5b7bc068f9b25f586e21 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:21:12 +0000 Subject: [PATCH 1/6] feat: send email content from code with posthog.emails.send Squash of Silthus/posthog-python#2 at e5fdedf, re-signed for required signatures. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 33 +- .sampo/changesets/emails-code-form.md | 9 + posthog/async_client.py | 11 +- posthog/client.py | 13 +- posthog/emails.py | 651 +++++++++++++++++++++++--- posthog/test/test_emails.py | 510 +++++++++++++++++++- references/public_api_snapshot.txt | 39 +- 7 files changed, 1174 insertions(+), 92 deletions(-) create mode 100644 .sampo/changesets/emails-code-form.md diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 7b46c0af0..67da012e8 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,18 @@ 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"], capture_engagement=False, max_retries=0) +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"}]) +_ = (template_send, rendered_send, local_template_send, invite_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 +77,25 @@ 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 +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_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", capture_engagement=False) # 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: + 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 +139,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 +148,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/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md new file mode 100644 index 000000000..df27efc68 --- /dev/null +++ b/.sampo/changesets/emails-code-form.md @@ -0,0 +1,9 @@ +--- +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 local template needs `html`. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. + +A send with `template_key` captures the email's opens and clicks unless it passes `capture_engagement=False`. A template made in PostHog follows the project's setting, so a `template_id` send doesn't take `capture_engagement`. A code send takes `max_retries` like a template send. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. + +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. An optional field of a code send passed as `None` counts as left out. diff --git a/posthog/async_client.py b/posthog/async_client.py index 57ed13e29..787705f96 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 ) @@ -260,8 +264,8 @@ def __init__( @property def emails(self) -> AsyncEmails: """ - Send transactional email from PostHog email templates, and read the - status of each email sent with ``get``. + Send transactional email from PostHog email templates or from content + in your code, and read the status of each email sent with ``get``. 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 @@ -288,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 388e20b2d..fda66cf2a 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -733,6 +733,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. @@ -793,6 +794,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. @@ -1068,10 +1071,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 @@ -1250,8 +1256,8 @@ def _request_identity_kwargs(self) -> Dict[str, str]: @property def emails(self) -> Emails: """ - Send transactional email from PostHog email templates, and read the - status of each email sent with ``get``. + Send transactional email from PostHog email templates or from content + in your code, and read the status of each email sent with ``get``. 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 @@ -1279,6 +1285,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 72b1ca3ef..c65af278b 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 @@ -44,6 +64,10 @@ :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: @@ -74,6 +98,8 @@ from __future__ import annotations import asyncio +import base64 +import hashlib import importlib import json import logging @@ -85,10 +111,21 @@ from dataclasses import dataclass from datetime import datetime 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 ( @@ -102,21 +139,30 @@ __all__ = [ "AsyncEmails", + "CodeEmailArgs", + "EmailAttachment", "EmailError", "EmailStatus", + "EmailTag", "EmailWarning", "Emails", "SendEmailResult", + "TemplateEmailArgs", ] log = logging.getLogger("posthog") _SEND_PATH = "/api/projects/@current/messaging/send/email/" _STATUS_PATH = "/api/projects/@current/messaging/emails/{message_id}/" +_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}$") +_HEADER_TOKEN = re.compile(r"[\x21-\x7e]+") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) _QUOTA_CODES = frozenset({"quota_limited"}) +_TEMPLATE_CONFLICTS = frozenset({"template_outdated", "template_edited_in_posthog"}) class EmailError(Exception): @@ -168,6 +214,142 @@ 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``, ``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, bytearray, memoryview, str] + content_type: NotRequired[str] + content_id: NotRequired[str] + + +class _RecipientArgs(TypedDict): + to: str + distinct_id: NotRequired[Optional[str]] + idempotency_key: NotRequired[Optional[str]] + max_retries: NotRequired[Optional[int]] + + +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. + max_retries: Overrides the client's maximum number of retries for this + send. ``0`` turns retries off. + """ + + 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. + + An optional field passed as ``None`` counts as left out. + + 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. A local template + needs ``html``. + 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. + 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 the email's opens and clicks as events of + the recipient. Defaults to ``True``. A project whose admin turned + engagement capture off captures nothing, whatever the send asks. A + template made in PostHog follows the project's setting, so the + template form doesn't take it. + 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 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. + max_retries: Overrides the client's maximum number of retries for this + send. ``0`` turns retries off. + """ + + template_key: str + subject: str + html: NotRequired[Optional[str]] + text: NotRequired[Optional[str]] + from_: NotRequired[Optional[str]] + reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] + cc: NotRequired[Optional[Union[str, Sequence[str]]]] + bcc: NotRequired[Optional[Union[str, Sequence[str]]]] + headers: NotRequired[Optional[Mapping[str, str]]] + tags: NotRequired[Optional[Sequence[EmailTag]]] + attachments: NotRequired[Optional[Sequence[EmailAttachment]]] + capture_engagement: NotRequired[Optional[bool]] + template_data: NotRequired[Optional[Mapping[str, Any]]] + + +_OPTIONAL_CODE_FIELDS = frozenset( + { + "html", + "text", + "from_", + "reply_to", + "cc", + "bcc", + "headers", + "tags", + "attachments", + "capture_engagement", + "template_data", + } +) + + +@dataclass(frozen=True) +class _PreparedUpload: + path: str + body: bytes + + @dataclass(frozen=True) class EmailStatus: """What happened to an email that ``send()`` queued. @@ -200,6 +382,219 @@ class _PreparedSend: body: bytes idempotency_key: str max_retries: Optional[int] = None + 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"), + args.get("max_retries"), + ) + return _prepare_code_send(args) + + +def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: + args = { + name: value + for name, value in args.items() + if value is not None or name not in _OPTIONAL_CODE_FIELDS + } + 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") + ) + max_retries = _retry_limit(args.get("max_retries")) + 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." + ) + 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.") + 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 + if "template_data" in args: + template_data = args["template_data"] + if not isinstance(template_data, Mapping): + raise TypeError("template_data must be a mapping.") + if not content.get("html"): + raise ValueError( + "A local template needs html. Pass the HTML body with template_data." + ) + payload["template"] = {"key": template_key, "hash": _content_hash(content)} + payload["message_data"] = clean(dict(template_data)) + if distinct_id is not None: + payload["identifiers"] = {"distinct_id": distinct_id} + upload = _PreparedUpload( + path=_UPLOAD_PATH.format(template_key=template_key), + body=_encode( + { + "name": template_key, + **content, + "if_not_superseded": True, + "if_code_managed": True, + } + ), + ) + else: + payload.update({"template_key": template_key, **content}) + if distinct_id is not None: + payload["distinct_id"] = distinct_id + payload.update(_sender(args)) + return _PreparedSend( + path=_SEND_PATH, + body=_encode(payload), + idempotency_key=idempotency_key, + max_retries=max_retries, + upload=upload, + ) + + +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, "").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_") + 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"]) + capture_engagement = args.get("capture_engagement", True) + if not isinstance(capture_engagement, bool): + raise TypeError("capture_engagement must be True or False.") + sender["capture_engagement"] = capture_engagement + return sender + + +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.") + if not all(address.strip() for address in addresses): + raise ValueError(f"{field} must not hold a blank address.") + 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, bytearray, memoryview)): + return base64.b64encode(bytes(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 _string(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 = _string(args, name) + if not value: + raise ValueError(f"{name} must not be empty.") + return value + + +def _encode(payload: Mapping[str, Any]) -> bytes: + return json.dumps( + payload, cls=DatetimeSerializer, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") def _prepare_send( @@ -210,10 +605,8 @@ def _prepare_send( idempotency_key: Optional[str], max_retries: Optional[int] = None, ) -> _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) + max_retries = _retry_limit(max_retries) try: template_uuid = ( template_id if isinstance(template_id, UUID) else UUID(template_id) @@ -222,6 +615,29 @@ 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.") + + payload: dict[str, Any] = { + "template": {"id": str(template_uuid)}, + "to": to, + "message_data": clean(dict(template_data or {})), + } + if distinct_id is not None: + payload["identifiers"] = {"distinct_id": distinct_id} + return _PreparedSend( + path=_SEND_PATH, + body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"), + idempotency_key=idempotency_key, + max_retries=max_retries, + ) + + +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 ): @@ -234,26 +650,17 @@ def _prepare_send( raise ValueError( "idempotency_key must be 1 to 255 visible ASCII characters without spaces." ) + return to, distinct_id, idempotency_key + + +def _retry_limit(max_retries: Any) -> Optional[int]: if max_retries is not None and ( isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0 ): raise ValueError("max_retries must be a nonnegative integer.") - - payload: dict[str, Any] = { - "template": {"id": str(template_uuid)}, - "to": to, - "message_data": clean(dict(template_data or {})), - } - if distinct_id is not None: - payload["identifiers"] = {"distinct_id": distinct_id} - return _PreparedSend( - path=_SEND_PATH, - body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"), - idempotency_key=idempotency_key, - max_retries=max_retries, - ) + return max_retries def _status_path(message_id: Union[str, UUID]) -> str: @@ -266,7 +673,9 @@ def _status_path(message_id: Union[str, UUID]) -> str: return _STATUS_PATH.format(message_id=message_uuid) -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)) @@ -280,6 +689,12 @@ def _validate_options(timeout: float, max_retries: int) -> None: or max_retries < 0 ): raise ValueError("max_retries must be a nonnegative integer.") + if migrated_from is not None and not ( + isinstance(migrated_from, str) and _HEADER_TOKEN.fullmatch(migrated_from) + ): + raise ValueError( + "migrated_from must be visible ASCII characters, such as 'resend'." + ) def _text(value: Any) -> Optional[str]: @@ -300,7 +715,7 @@ def _error_name(status: Optional[int], code: str) -> str: if status == 409: return ( "validation_error" - if code == "template_outdated" + if code in _TEMPLATE_CONFLICTS else "concurrent_idempotent_requests" ) if status == 422: @@ -402,6 +817,12 @@ def _parse_status_response( ) +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 _response_error(fields, status, retry_after_header) + + def _response_error( fields: Mapping[str, Any], status: int, retry_after_header: Optional[str] ) -> EmailError: @@ -444,20 +865,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: Optional[str]) -> dict[str, str]: +def _headers( + secret_key: str, + migrated_from: Optional[str], + *, + has_body: bool = False, + idempotency_key: Optional[str] = None, +) -> dict[str, str]: headers = { "Authorization": f"Bearer {secret_key}", "Accept": "application/json", "User-Agent": f"posthog-python/{VERSION}", } - if idempotency_key is not None: + if has_body: headers["Content-Type"] = "application/json" + 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 @@ -491,13 +925,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 @@ -526,7 +962,7 @@ def _secret_key(self, key_needed: str) -> 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 @@ -536,7 +972,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__( @@ -548,6 +985,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, @@ -555,22 +993,25 @@ 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 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, - max_retries: Optional[int] = 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. @@ -608,17 +1049,33 @@ 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, max_retries - ) + prepared = _prepare(kwargs) finish = self._start(_SEND_NOT_DONE) try: - headers = _headers( - self._secret_key(_SEND_KEY_NEEDED), prepared.idempotency_key - ) - result = self._call( + result = self._deliver(prepared, self._secret_key(_SEND_KEY_NEEDED)) + finally: + finish() + _log_warnings(result) + return result + + def _deliver(self, prepared: _PreparedSend, secret_key: str) -> SendEmailResult: + headers = _headers( + secret_key, + self.migrated_from, + has_body=True, + idempotency_key=prepared.idempotency_key, + ) + try: + return self._call( "POST", prepared.path, prepared.body, @@ -626,10 +1083,25 @@ def send( _parse_response, prepared.max_retries, ) - finally: - finish() - _log_warnings(result) - return result + except EmailError as error: + if prepared.upload is None or not _is_outdated(error): + raise + self._call( + "PUT", + prepared.upload.path, + prepared.upload.body, + _headers(secret_key, self.migrated_from, has_body=True), + _parse_upload, + prepared.max_retries, + ) + return self._call( + "POST", + prepared.path, + prepared.body, + headers, + _parse_response, + prepared.max_retries, + ) def get(self, message_id: Union[str, UUID]) -> EmailStatus: """Read what happened to an email that :meth:`send` queued. @@ -658,7 +1130,7 @@ def get(self, message_id: Union[str, UUID]) -> EmailStatus: path = _status_path(message_id) finish = self._start(_GET_NOT_DONE) try: - headers = _headers(self._secret_key(_GET_KEY_NEEDED), None) + headers = _headers(self._secret_key(_GET_KEY_NEEDED), self.migrated_from) return self._call("GET", path, None, headers, _parse_status_response) finally: finish() @@ -753,6 +1225,7 @@ def __init__( max_retries: int = 2, disabled: bool = False, http_client: Any = None, + migrated_from: Optional[str] = None, ) -> None: super().__init__( secret_key, @@ -760,21 +1233,19 @@ 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 - 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, - max_retries: Optional[int] = 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`. @@ -789,15 +1260,26 @@ async def send( ) ``` """ - prepared = _prepare_send( - to, template_id, template_data, distinct_id, idempotency_key, max_retries - ) + prepared = _prepare(kwargs) finish = self._start(_SEND_NOT_DONE) try: - headers = _headers( - self._secret_key(_SEND_KEY_NEEDED), prepared.idempotency_key - ) - result = await self._call( + result = await self._deliver(prepared, self._secret_key(_SEND_KEY_NEEDED)) + finally: + finish() + _log_warnings(result) + return result + + async def _deliver( + self, prepared: _PreparedSend, secret_key: str + ) -> SendEmailResult: + headers = _headers( + secret_key, + self.migrated_from, + has_body=True, + idempotency_key=prepared.idempotency_key, + ) + try: + return await self._call( "POST", prepared.path, prepared.body, @@ -805,10 +1287,25 @@ async def send( _parse_response, prepared.max_retries, ) - finally: - finish() - _log_warnings(result) - return result + except EmailError as error: + if prepared.upload is None or not _is_outdated(error): + raise + await self._call( + "PUT", + prepared.upload.path, + prepared.upload.body, + _headers(secret_key, self.migrated_from, has_body=True), + _parse_upload, + prepared.max_retries, + ) + return await self._call( + "POST", + prepared.path, + prepared.body, + headers, + _parse_response, + prepared.max_retries, + ) async def get(self, message_id: Union[str, UUID]) -> EmailStatus: """Read what happened to an email that :meth:`send` queued. @@ -819,7 +1316,7 @@ async def get(self, message_id: Union[str, UUID]) -> EmailStatus: path = _status_path(message_id) finish = self._start(_GET_NOT_DONE) try: - headers = _headers(self._secret_key(_GET_KEY_NEEDED), None) + headers = _headers(self._secret_key(_GET_KEY_NEEDED), self.migrated_from) return await self._call("GET", path, None, headers, _parse_status_response) finally: finish() @@ -915,10 +1412,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 @@ -930,6 +1432,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 0d2099a86..c7fc3cd82 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -176,6 +176,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"}) @@ -188,6 +189,35 @@ 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://posthog.example.com/").prepare() + ) + assert sent.headers["X-PostHog-Migrated-From"] == "resend" + + +def test_names_the_provider_the_client_moved_from_on_every_request(): + emails, adapter = emails_with([accepted(), status_reply()], migrated_from="resend") + + send(emails) + emails.get(MESSAGE_ID) + + assert [ + request.headers["X-PostHog-Migrated-From"] for request in adapter.requests + ] == [ + "resend", + "resend", + ] + + +@parameterized.expand([("space", "re send"), ("not_text", 1), ("empty", "")]) +def test_rejects_a_provider_name_that_is_not_a_header_value(_name, migrated_from): + with pytest.raises(ValueError): + Posthog( + "phc_test", + secret_key=SECRET_KEY, + sync_mode=True, + migrated_from=migrated_from, + ) @parameterized.expand( @@ -282,6 +312,18 @@ def test_waits_for_retry_after_and_gives_up_on_a_longer_one(no_sleep): ("client_option", {"max_retries": 0}, {}, 1), ("per_send_override", {}, {"max_retries": 0}, 1), ("per_send_override_above_client", {"max_retries": 0}, {"max_retries": 1}, 2), + ( + "code_send_override", + {}, + { + "template_id": None, + "template_key": "password-reset", + "subject": "Reset", + "html": "

Reset

", + "max_retries": 0, + }, + 1, + ), ] ) def test_max_retries_zero_leaves_retries_to_the_caller( @@ -290,9 +332,12 @@ def test_max_retries_zero_leaves_retries_to_the_caller( emails, adapter = emails_with( [rejected(503, "delivery_unavailable")] * 3, **client_options ) + args = {"to": "user@example.com", "template_id": TEMPLATE_ID, **send_options} with pytest.raises(EmailError): - send(emails, **send_options) + emails.send( + **{name: value for name, value in args.items() if value is not None} + ) assert len(adapter.requests) == requests_made @@ -572,3 +617,466 @@ def handler(request: httpx.Request) -> httpx.Response: assert [(request.method, str(request.url)) for request in seen] == [ ("GET", STATUS_URL) ] * 2 + + +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, + "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"]}, + ), + ( + "empty_address_lists", + {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, + {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, + ), + ( + "none_as_left_out", + { + "html": HTML, + "text": None, + "from_": None, + "reply_to": None, + "cc": None, + "bcc": None, + "headers": None, + "tags": None, + "attachments": None, + "capture_engagement": None, + "template_data": None, + "distinct_id": None, + }, + {"html": HTML}, + ), + ] +) +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", 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", + "capture_engagement": True, + **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), + ("empty_text", {"text": ""}, WELCOME_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", SEND_URL) + assert json.loads(request.body) == { + "to": "user@example.com", + "template": {"key": "welcome", "hash": hash}, + "message_data": {"first_name": "Jane"}, + "from": "Acme ", + "capture_engagement": True, + "identifiers": {"distinct_id": "user_123"}, + } + + +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(): + 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"), + "template_edited_in_posthog", + 2, + ), + ("outdated_again", None, "template_outdated", 3), + ] +) +def test_a_local_template_uploads_at_most_once_per_send( + _name, upload_reply, code, 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.code, raised.value.name) == ( + 409, + code, + "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) + + 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] == [ + ("POST", SEND_URL), + ("PUT", UPLOAD_URL), + ("POST", 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"] + ) + + +@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), + ( + "key_with_space", + {"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), + ( + "local_template_without_html", + {"text": "Hi", "template_data": {"first_name": "Jane"}}, + ValueError, + ), + ( + "local_template_with_empty_html", + {"html": "", "text": "Hi", "template_data": {"first_name": "Jane"}}, + ValueError, + ), + ( + "capture_engagement_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "capture_engagement": False, + }, + 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): + 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 == [] + + +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, + "capture_engagement": True, + **COPIES_HEADERS_AND_TAGS_BODY, + } + + +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()] + ) + + 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( + [ + ("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): + 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"104"}), + ("unknown_field", {"filename": "a.txt", "content": b"104", "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 3eadb81bd..2eb20a585 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -758,6 +758,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() @@ -828,6 +829,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 @@ -884,6 +886,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[Optional[Sequence[EmailAttachment]]] +attribute posthog.emails.CodeEmailArgs.bcc: NotRequired[Optional[Union[str, Sequence[str]]]] +attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]] +attribute posthog.emails.CodeEmailArgs.cc: NotRequired[Optional[Union[str, Sequence[str]]]] +attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[str]] +attribute posthog.emails.CodeEmailArgs.headers: NotRequired[Optional[Mapping[str, str]]] +attribute posthog.emails.CodeEmailArgs.html: NotRequired[Optional[str]] +attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] +attribute posthog.emails.CodeEmailArgs.subject: str +attribute posthog.emails.CodeEmailArgs.tags: NotRequired[Optional[Sequence[EmailTag]]] +attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]] +attribute posthog.emails.CodeEmailArgs.template_key: str +attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[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 attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -898,12 +917,16 @@ attribute posthog.emails.EmailStatus.id: str attribute posthog.emails.EmailStatus.reason: Optional[str] = None attribute posthog.emails.EmailStatus.status: str attribute posthog.emails.EmailStatus.status_at: datetime +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 attribute posthog.emails.SendEmailResult.id: 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 @@ -1320,21 +1343,25 @@ 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: float = 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: float = 3, feature_flags_request_max_retries: int = 1, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2) +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: float = 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: float = 3, feature_flags_request_max_retries: int = 1, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, 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: float = 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: float = 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: float = 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: float = 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: float = 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.EmailStatus(id: str, status: str, created_at: datetime, status_at: datetime, reason: Optional[str] = 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.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, 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) @@ -1833,10 +1860,10 @@ 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.get(message_id: Union[str, UUID]) -> EmailStatus -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, max_retries: Optional[int] = None) -> SendEmailResult +method posthog.emails.AsyncEmails.send(**kwargs: Any) -> SendEmailResult method posthog.emails.Emails.close() -> None method posthog.emails.Emails.get(message_id: Union[str, UUID]) -> EmailStatus -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, max_retries: Optional[int] = 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 20fad8c767d0be1c45047ca8b4123e23e53710d8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:09:26 +0000 Subject: [PATCH 2/6] feat: cut emails.send to the template and rendered code forms Parks the local-template form (template_data on a code send, the content hash, the upload by key and its retry) and the cc, bcc, headers, tags and attachments fields, to match posthog-node. A code send now takes rendered html and/or text only. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 7 +- .sampo/changesets/emails-code-form.md | 6 +- posthog/emails.py | 245 +----------------- posthog/test/test_emails.py | 341 +------------------------- references/public_api_snapshot.txt | 14 -- 5 files changed, 21 insertions(+), 592 deletions(-) diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 67da012e8..4e41c9e8f 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -44,9 +44,7 @@ _ = (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"], capture_engagement=False, max_retries=0) -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"}]) -_ = (template_send, rendered_send, local_template_send, invite_send) +_ = (template_send, rendered_send) async def async_email_types() -> None: @@ -91,7 +89,8 @@ Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3 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_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", capture_engagement=False) # 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 +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", template_data={"name": "Ada"}) # expected-type-error +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", cc="team@example.com") # 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 df27efc68..f02c6d9bd 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -2,8 +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 local template needs `html`. 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=...)`. The client sends the content as is, so render a React email to HTML in your app first. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. -A send with `template_key` captures the email's opens and clicks unless it passes `capture_engagement=False`. A template made in PostHog follows the project's setting, so a `template_id` send doesn't take `capture_engagement`. A code send takes `max_retries` like a template send. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. - -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. An optional field of a code send passed as `None` counts as left out. +A send with `template_key` captures the email's opens and clicks unless it passes `capture_engagement=False`. A template made in PostHog follows the project's setting, so a `template_id` send doesn't take `capture_engagement`. A code send takes `from_`, `reply_to` and `max_retries`. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. An optional field of a code send passed as `None` counts as left out. diff --git a/posthog/emails.py b/posthog/emails.py index 39e8262de..8237e2b47 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -31,17 +31,6 @@ 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 @@ -98,8 +87,6 @@ from __future__ import annotations import asyncio -import base64 -import hashlib import importlib import json import logging @@ -140,10 +127,8 @@ __all__ = [ "AsyncEmails", "CodeEmailArgs", - "EmailAttachment", "EmailError", "EmailStatus", - "EmailTag", "EmailWarning", "Emails", "SendEmailResult", @@ -154,15 +139,12 @@ _SEND_PATH = "/api/projects/@current/messaging/send/email/" _STATUS_PATH = "/api/projects/@current/messaging/emails/{message_id}/" -_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}$") _HEADER_TOKEN = re.compile(r"[\x21-\x7e]+") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) _QUOTA_CODES = frozenset({"quota_limited"}) -_TEMPLATE_CONFLICTS = frozenset({"template_outdated", "template_edited_in_posthog"}) class EmailError(Exception): @@ -214,31 +196,6 @@ 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``, ``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, bytearray, memoryview, str] - content_type: NotRequired[str] - content_id: NotRequired[str] - - class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] @@ -265,43 +222,29 @@ class TemplateEmailArgs(_RecipientArgs): class CodeEmailArgs(_RecipientArgs): - """A send of email content that lives in your code, named by a key. + """A send of email content that your code rendered for one recipient, 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. + The content goes out as is. To send a React email, render it to HTML in + your app first. An optional field passed as ``None`` counts as left out. - 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. A local template - needs ``html``. + html: The HTML body. Give ``html``, ``text`` or both. 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. - 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 the email's opens and clicks as events of the recipient. Defaults to ``True``. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A template made in PostHog follows the project's setting, so the template form doesn't take it. - 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 sends and code sends share one set of keys: a key already used for @@ -318,13 +261,7 @@ class CodeEmailArgs(_RecipientArgs): text: NotRequired[Optional[str]] from_: NotRequired[Optional[str]] reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] - cc: NotRequired[Optional[Union[str, Sequence[str]]]] - bcc: NotRequired[Optional[Union[str, Sequence[str]]]] - headers: NotRequired[Optional[Mapping[str, str]]] - tags: NotRequired[Optional[Sequence[EmailTag]]] - attachments: NotRequired[Optional[Sequence[EmailAttachment]]] capture_engagement: NotRequired[Optional[bool]] - template_data: NotRequired[Optional[Mapping[str, Any]]] _OPTIONAL_CODE_FIELDS = frozenset( @@ -333,23 +270,11 @@ class CodeEmailArgs(_RecipientArgs): "text", "from_", "reply_to", - "cc", - "bcc", - "headers", - "tags", - "attachments", "capture_engagement", - "template_data", } ) -@dataclass(frozen=True) -class _PreparedUpload: - path: str - body: bytes - - @dataclass(frozen=True) class EmailStatus: """What happened to an email that ``send()`` queued. @@ -382,7 +307,6 @@ class _PreparedSend: body: bytes idempotency_key: str max_retries: Optional[int] = None - upload: Optional[_PreparedUpload] = None def _prepare(args: Mapping[str, Any]) -> _PreparedSend: @@ -436,31 +360,7 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: 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 - if "template_data" in args: - template_data = args["template_data"] - if not isinstance(template_data, Mapping): - raise TypeError("template_data must be a mapping.") - if not content.get("html"): - raise ValueError( - "A local template needs html. Pass the HTML body with template_data." - ) - 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, - **content, - "if_not_superseded": True, - "if_code_managed": True, - } - ), - ) - else: - payload.update({"template_key": template_key, **content}) + payload: dict[str, Any] = {"to": to, "template_key": template_key, **content} if distinct_id is not None: payload["distinct_id"] = distinct_id payload.update(_sender(args)) @@ -469,33 +369,15 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: body=_encode(payload), idempotency_key=idempotency_key, max_retries=max_retries, - upload=upload, ) -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, "").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_") - 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 "reply_to" in args: + sender["reply_to"] = _addresses("reply_to", args["reply_to"]) capture_engagement = args.get("capture_engagement", True) if not isinstance(capture_engagement, bool): raise TypeError("capture_engagement must be True or False.") @@ -514,67 +396,6 @@ def _addresses(field: str, value: Any) -> list[str]: 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, bytearray, memoryview)): - return base64.b64encode(bytes(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 _string(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str): @@ -713,7 +534,7 @@ def _error_name(status: Optional[int], code: str) -> str: if status == 409: return ( "validation_error" - if code in _TEMPLATE_CONFLICTS + if code == "template_outdated" else "concurrent_idempotent_requests" ) if status == 422: @@ -815,12 +636,6 @@ def _parse_status_response( ) -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 _response_error(fields, status, retry_after_header) - - def _response_error( fields: Mapping[str, Any], status: int, retry_after_header: Optional[str] ) -> EmailError: @@ -863,10 +678,6 @@ 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) @@ -1072,26 +883,6 @@ def _deliver(self, prepared: _PreparedSend, secret_key: str) -> SendEmailResult: has_body=True, idempotency_key=prepared.idempotency_key, ) - try: - return self._call( - "POST", - prepared.path, - prepared.body, - headers, - _parse_response, - prepared.max_retries, - ) - except EmailError as error: - if prepared.upload is None or not _is_outdated(error): - raise - self._call( - "PUT", - prepared.upload.path, - prepared.upload.body, - _headers(secret_key, self.migrated_from, has_body=True), - _parse_upload, - prepared.max_retries, - ) return self._call( "POST", prepared.path, @@ -1276,26 +1067,6 @@ async def _deliver( has_body=True, idempotency_key=prepared.idempotency_key, ) - try: - return await self._call( - "POST", - prepared.path, - prepared.body, - headers, - _parse_response, - prepared.max_retries, - ) - except EmailError as error: - if prepared.upload is None or not _is_outdated(error): - raise - await self._call( - "PUT", - prepared.upload.path, - prepared.upload.body, - _headers(secret_key, self.migrated_from, has_body=True), - _parse_upload, - prepared.max_retries, - ) return await self._call( "POST", prepared.path, diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 8ba561324..96fff6dac 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -663,8 +663,8 @@ def send_code(emails: Emails, **overrides: Any): ), ( "empty_address_lists", - {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, - {"html": HTML, "reply_to": [], "cc": [], "bcc": []}, + {"html": HTML, "reply_to": []}, + {"html": HTML, "reply_to": []}, ), ( "none_as_left_out", @@ -673,13 +673,7 @@ def send_code(emails: Emails, **overrides: Any): "text": None, "from_": None, "reply_to": None, - "cc": None, - "bcc": None, - "headers": None, - "tags": None, - "attachments": None, "capture_engagement": None, - "template_data": None, "distinct_id": None, }, {"html": HTML}, @@ -704,170 +698,6 @@ def test_rendered_content_is_posted_as_is(_name, overrides, 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), - ("empty_text", {"text": ""}, WELCOME_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", SEND_URL) - assert json.loads(request.body) == { - "to": "user@example.com", - "template": {"key": "welcome", "hash": hash}, - "message_data": {"first_name": "Jane"}, - "from": "Acme ", - "capture_engagement": True, - "distinct_id": "user_123", - } - - -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(): - 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"), - "template_edited_in_posthog", - 2, - ), - ("outdated_again", None, "template_outdated", 3), - ] -) -def test_a_local_template_uploads_at_most_once_per_send( - _name, upload_reply, code, 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.code, raised.value.name) == ( - 409, - code, - "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) - - 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] == [ - ("POST", SEND_URL), - ("PUT", UPLOAD_URL), - ("POST", 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"] - ) - - @parameterized.expand( [ ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError), @@ -890,19 +720,14 @@ def handler(request: httpx.Request) -> httpx.Response: ("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), - ( - "local_template_without_html", - {"text": "Hi", "template_data": {"first_name": "Jane"}}, - ValueError, - ), + ("blank_reply_to", {"html": HTML, "reply_to": " "}, ValueError), + ("reply_to_not_text", {"html": HTML, "reply_to": 123}, TypeError), ( - "local_template_with_empty_html", - {"html": "", "text": "Hi", "template_data": {"first_name": "Jane"}}, - ValueError, + "template_data_on_code_form", + {"html": HTML, "template_data": {"first_name": "Jane"}}, + TypeError, ), + ("cc_on_code_form", {"html": HTML, "cc": "a@example.com"}, TypeError), ( "capture_engagement_on_template_form", { @@ -913,16 +738,6 @@ def handler(request: httpx.Request) -> httpx.Response: }, 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): @@ -940,143 +755,3 @@ 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, - "capture_engagement": True, - **COPIES_HEADERS_AND_TAGS_BODY, - } - - -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()] - ) - - 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( - [ - ("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): - 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"104"}), - ("unknown_field", {"filename": "a.txt", "content": b"104", "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 e3a4ae521..86395957e 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -886,23 +886,13 @@ attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False -attribute posthog.emails.CodeEmailArgs.attachments: NotRequired[Optional[Sequence[EmailAttachment]]] -attribute posthog.emails.CodeEmailArgs.bcc: NotRequired[Optional[Union[str, Sequence[str]]]] attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]] -attribute posthog.emails.CodeEmailArgs.cc: NotRequired[Optional[Union[str, Sequence[str]]]] attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[str]] -attribute posthog.emails.CodeEmailArgs.headers: NotRequired[Optional[Mapping[str, str]]] attribute posthog.emails.CodeEmailArgs.html: NotRequired[Optional[str]] attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] attribute posthog.emails.CodeEmailArgs.subject: str -attribute posthog.emails.CodeEmailArgs.tags: NotRequired[Optional[Sequence[EmailTag]]] -attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]] attribute posthog.emails.CodeEmailArgs.template_key: str attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[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 attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -917,8 +907,6 @@ attribute posthog.emails.EmailStatus.id: str attribute posthog.emails.EmailStatus.reason: Optional[str] = None attribute posthog.emails.EmailStatus.status: str attribute posthog.emails.EmailStatus.status_at: datetime -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 @@ -1354,10 +1342,8 @@ 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, 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.EmailStatus(id: str, status: str, created_at: datetime, status_at: datetime, reason: Optional[str] = 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, migrated_from: Optional[str] = None) class posthog.emails.SendEmailResult(id: str, replayed: bool = False, warnings: tuple[EmailWarning, ...] = ()) From 9a4dd48c905b458ba299a8940350f6b1e2e4f572 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:23:14 +0000 Subject: [PATCH 3/6] test(emails): cover the async code-form send and migrated_from Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_emails.py | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 96fff6dac..f84a68ee6 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -698,6 +698,50 @@ def test_rendered_content_is_posted_as_is(_name, overrides, extra_body): } +@pytest.mark.asyncio +async def test_async_client_posts_rendered_content_and_names_the_provider(): + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(202, json={"message_id": "msg_1", "replayed": True}) + + client = AsyncPosthog("phc_test", secret_key=SECRET_KEY, migrated_from="resend") + client.emails._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + result = await client.emails.send( + to="user@example.com", + template_key="password-reset", + subject="Reset your password", + html=HTML, + text="Reset it here.", + from_="Acme ", + reply_to="help@example.com", + capture_engagement=False, + distinct_id="user_123", + idempotency_key="reset:user_123", + ) + await client.shutdown() + + assert (result.id, result.replayed) == ("msg_1", True) + [request] = seen + assert (request.method, str(request.url)) == ("POST", SEND_URL) + assert request.headers["Authorization"] == f"Bearer {SECRET_KEY}" + assert request.headers["Idempotency-Key"] == "reset:user_123" + assert request.headers["X-PostHog-Migrated-From"] == "resend" + assert json.loads(request.content) == { + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + "html": HTML, + "text": "Reset it here.", + "from": "Acme ", + "reply_to": ["help@example.com"], + "capture_engagement": False, + "distinct_id": "user_123", + } + + @parameterized.expand( [ ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError), From b93ea5c117741c19427bb215f570b0a33528027a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:42:38 +0000 Subject: [PATCH 4/6] fix(emails): leave reply_to out when the list is empty Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 7 ++++--- posthog/test/test_emails.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/posthog/emails.py b/posthog/emails.py index 8237e2b47..16f342777 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -239,7 +239,7 @@ class CodeEmailArgs(_RecipientArgs): ``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. + reply_to: One address or a list of addresses. An empty list sends no reply-to. capture_engagement: Capture the email's opens and clicks as events of the recipient. Defaults to ``True``. A project whose admin turned engagement capture off captures nothing, whatever the send asks. A @@ -376,8 +376,9 @@ 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("reply_to", args["reply_to"]) + reply_to = _addresses("reply_to", args["reply_to"]) if "reply_to" in args else [] + if reply_to: + sender["reply_to"] = reply_to capture_engagement = args.get("capture_engagement", True) if not isinstance(capture_engagement, bool): raise TypeError("capture_engagement must be True or False.") diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index f84a68ee6..eaf1c0949 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -662,9 +662,9 @@ def send_code(emails: Emails, **overrides: Any): {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, ), ( - "empty_address_lists", - {"html": HTML, "reply_to": []}, + "empty_reply_to_left_out", {"html": HTML, "reply_to": []}, + {"html": HTML}, ), ( "none_as_left_out", From 8e23d137a6c8b7f3f3498340d4f817bd4aadb7e7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:48:16 +0000 Subject: [PATCH 5/6] fix(emails): name the transactional_email scopes for sends and status reads Co-Authored-By: Claude Opus 5.5 --- posthog/async_client.py | 2 +- posthog/client.py | 2 +- posthog/emails.py | 18 ++++++++++-------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/posthog/async_client.py b/posthog/async_client.py index 787705f96..5b5d20a2b 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -267,7 +267,7 @@ def emails(self) -> AsyncEmails: Send transactional email from PostHog email templates or from content in your code, and read the status of each email sent with ``get``. - Needs ``secret_key``, a project secret API key with the ``hog_flow:write`` + Needs ``secret_key``, a project secret API key with the ``transactional_email:write`` scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog Cloud app host of ``host``. ``email_request_timeout_seconds`` and ``email_request_max_retries`` configure each send. A disabled or shut-down diff --git a/posthog/client.py b/posthog/client.py index cce11b7ea..1eb0a2af8 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1259,7 +1259,7 @@ def emails(self) -> Emails: Send transactional email from PostHog email templates or from content in your code, and read the status of each email sent with ``get``. - Needs ``secret_key``, a project secret API key with the ``hog_flow:write`` + Needs ``secret_key``, a project secret API key with the ``transactional_email: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 diff --git a/posthog/emails.py b/posthog/emails.py index 16f342777..c53528c7c 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -4,8 +4,10 @@ 1. In PostHog, open the email template under Workflows, make it transactional, and copy its ID. ``template_id`` is that UUID. -2. Create a project secret API key (``phs_...``) with Workflow write access - (``hog_flow:write``) in your project settings. The project API key +2. Create a project secret API key (``phs_...``) with the "Send transactional + email" preset (``transactional_email:write``) in your project settings. Add + ``transactional_email:read`` too if you read email statuses with a key that + can't send. Keys made with ``hog_flow:write`` still work. The project API key (``phc_...``) captures events but cannot send email. 3. Send from the client you already use, or from :class:`Emails` in a process that only sends email: @@ -63,7 +65,7 @@ - ``missing_secret_key``: the client has no ``secret_key``. - ``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``. + wrong, or it lacks ``transactional_email:write``. - ``not_found`` (404): the template ID is wrong, or transactional email is not enabled for the project. - ``client_disabled``: the client is disabled, so it sends nothing. @@ -71,9 +73,9 @@ Status: ``emails.get(message_id)`` reads what happened to a sent email, for 30 days after the send, as an :class:`EmailStatus`. A key with only -``hog_flow:read`` can read statuses but not send. ``get`` raises ``not_found`` -(404) for an unknown ID, an email older than 30 days, or for a few seconds -after the send. +``transactional_email:read`` can read statuses but not send. ``get`` raises +``not_found`` (404) for an unknown ID, an email older than 30 days, or for a +few seconds after the send. Cleanup: ``shutdown()`` on the client closes its email connections. Close a standalone :class:`Emails` with ``close()`` or a ``with`` block. @@ -707,10 +709,10 @@ def _headers( _SEND_NOT_DONE = "no email was sent" _GET_NOT_DONE = "no email status was read" -_SEND_KEY_NEEDED = "Sending email needs secret_key, a project secret API key with the hog_flow:write scope." +_SEND_KEY_NEEDED = "Sending email needs secret_key, a project secret API key with the transactional_email:write scope." _GET_KEY_NEEDED = ( "Reading an email status needs secret_key, a project secret API key with the " - "hog_flow:read or hog_flow:write scope." + "transactional_email:read or transactional_email:write scope." ) _ResultT = TypeVar("_ResultT") _Parse = Callable[[int, Any, Optional[str]], _ResultT] From 268c59b2e2e172e1dff7115839f5c4c5f1f18b29 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:59:50 +0000 Subject: [PATCH 6/6] docs(emails): rewrite the release note for transactional email Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-code-form.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md index f02c6d9bd..4264b2b9c 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -2,6 +2,27 @@ pypi/posthog: minor --- -Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. The client sends the content as is, so render a React email to HTML in your app first. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. +`posthog.emails.send()` can now send email content your code rendered, such as HTML from Jinja or React Email. Give the email a `template_key` and a `subject`, and pass `html`, `text` or both. -A send with `template_key` captures the email's opens and clicks unless it passes `capture_engagement=False`. A template made in PostHog follows the project's setting, so a `template_id` send doesn't take `capture_engagement`. A code send takes `from_`, `reply_to` and `max_retries`. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. An optional field of a code send passed as `None` counts as left out. +```python +posthog = Posthog("phc_...", secret_key="phs_...") + +result = posthog.emails.send( + to="user@example.com", + distinct_id="user_123", + template_key="password-reset", + subject="Reset your password", + html=rendered_html, +) + +# "delivered", "bounced", ... +status = posthog.emails.get(result.id).status +``` + +You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secret_key`. Set `app_host` to your PostHog app URL when you self-host PostHog or send through a reverse proxy. + +PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too. + +A send with your own content also takes `from_`, `reply_to` and `capture_engagement=False`. Set `migrated_from` on the client to tell PostHog which email provider you moved from. A failed call raises an `EmailError` with PostHog's `code` and the HTTP `status_code`. + +Docs: [Send transactional emails with Python](https://posthog.com/docs/workflows/transactional-emails/python) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).