From 60d6d3077e38f61414b390e70af414c780306e29 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:22:21 +0000 Subject: [PATCH 1/8] feat(emails): send a message category from code emails.send takes an optional category on the template_key form. Left out or TRANSACTIONAL_CATEGORY sends transactional email; any other value is the key of a marketing message category in the project. The SDK sends the field only when the caller sets it. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 10 ++++-- .sampo/changesets/emails-send-category.md | 5 +++ posthog/emails.py | 40 ++++++++++++++++++++++- posthog/test/test_emails.py | 28 +++++++++++++++- references/public_api_snapshot.txt | 3 ++ 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .sampo/changesets/emails-send-category.md diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 4e41c9e8f..7d3b1b06c 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -15,7 +15,7 @@ import atexit import posthog from posthog import FeatureFlagEvaluations, FlagValue, Posthog -from posthog.emails import AsyncEmails, Emails, SendEmailResult +from posthog.emails import TRANSACTIONAL_CATEGORY, AsyncEmails, EmailCategory, Emails, SendEmailResult from posthog.ai.evaluations import ( AsyncOfflineEvaluations, BooleanScorerConfig, @@ -43,8 +43,10 @@ _ = (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) -_ = (template_send, rendered_send) +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, category="product-updates", max_retries=0) +category: EmailCategory = TRANSACTIONAL_CATEGORY +transactional_send: SendEmailResult = emails.send(to="user@example.com", template_key="receipt", subject="Receipt", html="

Paid

", category=category) +_ = (template_send, rendered_send, transactional_send) async def async_email_types() -> None: @@ -91,6 +93,8 @@ Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3 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

", 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 +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", category=1) # expected-type-error +Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", category="product-updates") # expected-type-error async def invalid_async_email() -> None: diff --git a/.sampo/changesets/emails-send-category.md b/.sampo/changesets/emails-send-category.md new file mode 100644 index 000000000..8d151e7f4 --- /dev/null +++ b/.sampo/changesets/emails-send-category.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out, or pass `TRANSACTIONAL_CATEGORY` (`"transactional"`), for transactional email, as before. Any other value is the key of one of the project's marketing message categories: the send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. `EmailCategory` types the value. PostHog answers a send it refuses with the error codes `unknown_category`, `unsubscribe_link_required` and `recipient_opted_out`. diff --git a/posthog/emails.py b/posthog/emails.py index c53528c7c..c16b9c527 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -33,8 +33,25 @@ html=rendered_html, distinct_id="user_123", ) + +# Marketing email in one of the project's message categories, named by its key +posthog.emails.send( + to="user@example.com", + template_key="november-digest", + subject="What's new in November", + html=digest_html, # links {{ unsubscribe_url }} + category="product-updates", + distinct_id="user_123", +) ``` +Message categories: an email you send from code is transactional unless you +pass ``category``. Any value other than :data:`TRANSACTIONAL_CATEGORY` is the +key of one of the project's marketing message categories. A marketing send +honors the recipient's opt-outs, and its body must link +``{{ unsubscribe_url }}``, which PostHog fills with the recipient's +preferences link. + ``posthog.emails`` above is the client's property. For async code, use ``AsyncPosthog`` or :class:`AsyncEmails` with ``posthog[async]`` installed. @@ -103,6 +120,8 @@ from typing import ( Any, Callable, + Final, + Literal, Mapping, Optional, Sequence, @@ -127,8 +146,10 @@ from .version import VERSION __all__ = [ + "TRANSACTIONAL_CATEGORY", "AsyncEmails", "CodeEmailArgs", + "EmailCategory", "EmailError", "EmailStatus", "EmailWarning", @@ -139,6 +160,9 @@ log = logging.getLogger("posthog") +TRANSACTIONAL_CATEGORY: Final = "transactional" +EmailCategory = Union[Literal["transactional"], str] + _SEND_PATH = "/api/projects/@current/messaging/send/email/" _STATUS_PATH = "/api/projects/@current/messaging/emails/{message_id}/" _TEMPLATE_KEY = re.compile(r"[a-zA-Z0-9_-]{1,400}") @@ -247,6 +271,16 @@ class CodeEmailArgs(_RecipientArgs): 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. + category: Left out, or :data:`TRANSACTIONAL_CATEGORY` + (``"transactional"``), sends transactional email. Any other value + is the key of one of the project's marketing message categories. + A marketing send honors the recipient's opt-outs, and its ``html``, + or its ``text`` when it has no ``html``, must hold + ``{{ unsubscribe_url }}``. PostHog raises ``unknown_category`` for + a key the project has no marketing category for, + ``unsubscribe_link_required`` for a body without the link, and + ``recipient_opted_out`` for a recipient who opted out of the + category or of all marketing. 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 @@ -264,6 +298,7 @@ class CodeEmailArgs(_RecipientArgs): from_: NotRequired[Optional[str]] reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] capture_engagement: NotRequired[Optional[bool]] + category: NotRequired[Optional[EmailCategory]] _OPTIONAL_CODE_FIELDS = frozenset( @@ -273,6 +308,7 @@ class CodeEmailArgs(_RecipientArgs): "from_", "reply_to", "capture_engagement", + "category", } ) @@ -366,6 +402,8 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: if distinct_id is not None: payload["distinct_id"] = distinct_id payload.update(_sender(args)) + if "category" in args: + payload["category"] = _nonempty_text(args, "category") return _PreparedSend( path=_SEND_PATH, body=_encode(payload), @@ -541,7 +579,7 @@ def _error_name(status: Optional[int], code: str) -> str: else "concurrent_idempotent_requests" ) if status == 422: - # The template send route also answers 422 for send checks such as recipient_suppressed. + # The send route also answers 422 for send checks such as recipient_suppressed and recipient_opted_out. return ( "invalid_idempotent_request" if code == "idempotency_key_reused" diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index eaf1c0949..fbc86a89e 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -14,7 +14,13 @@ from parameterized import parameterized from posthog import AsyncPosthog, Posthog -from posthog.emails import AsyncEmails, EmailError, Emails, EmailStatus +from posthog.emails import ( + TRANSACTIONAL_CATEGORY, + AsyncEmails, + EmailError, + Emails, + EmailStatus, +) from posthog.version import VERSION SECRET_KEY = "phs_test_secret" @@ -645,6 +651,7 @@ def send_code(emails: Emails, **overrides: Any): "from_": "Acme ", "reply_to": "help@example.com", "capture_engagement": False, + "category": "product-updates", "distinct_id": "user_123", }, { @@ -653,9 +660,15 @@ def send_code(emails: Emails, **overrides: Any): "from": "Acme ", "reply_to": ["help@example.com"], "capture_engagement": False, + "category": "product-updates", "distinct_id": "user_123", }, ), + ( + "transactional_category_sent_as_set", + {"html": HTML, "category": TRANSACTIONAL_CATEGORY}, + {"html": HTML, "category": "transactional"}, + ), ( "several_reply_to", {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, @@ -674,6 +687,7 @@ def send_code(emails: Emails, **overrides: Any): "from_": None, "reply_to": None, "capture_engagement": None, + "category": None, "distinct_id": None, }, {"html": HTML}, @@ -766,6 +780,8 @@ def handler(request: httpx.Request) -> httpx.Response: ("empty_html_and_text", {"html": "", "text": ""}, ValueError), ("blank_reply_to", {"html": HTML, "reply_to": " "}, ValueError), ("reply_to_not_text", {"html": HTML, "reply_to": 123}, TypeError), + ("category_not_text", {"html": HTML, "category": 1}, TypeError), + ("empty_category", {"html": HTML, "category": ""}, ValueError), ( "template_data_on_code_form", {"html": HTML, "template_data": {"first_name": "Jane"}}, @@ -782,6 +798,16 @@ def handler(request: httpx.Request) -> httpx.Response: }, TypeError, ), + ( + "category_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "category": "product-updates", + }, + TypeError, + ), ] ) def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index bfffd5d64..677d8476f 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -889,12 +889,14 @@ attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]] +attribute posthog.emails.CodeEmailArgs.category: NotRequired[Optional[EmailCategory]] attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[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.template_key: str attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[str]] +attribute posthog.emails.EmailCategory = Union[Literal['transactional'], str] attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -915,6 +917,7 @@ 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.TRANSACTIONAL_CATEGORY: Final = 'transactional' 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 From 65aba14a4d0e0c0ad99d5323bd1973a6fe0cc16a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:23:05 +0000 Subject: [PATCH 2/8] feat(emails): define the message category in code category on a code send is now TRANSACTIONAL_CATEGORY or a MessageCategory with a key, type, name and optional description. The server creates or updates the category by key, so the wire carries the object instead of a bare key. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_strict_types.sh | 14 ++-- .sampo/changesets/emails-send-category.md | 2 +- posthog/emails.py | 85 ++++++++++++++++++----- posthog/test/test_emails.py | 62 +++++++++++++++-- references/public_api_snapshot.txt | 8 ++- 5 files changed, 137 insertions(+), 34 deletions(-) diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 7d3b1b06c..0088f2753 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -15,7 +15,7 @@ import atexit import posthog from posthog import FeatureFlagEvaluations, FlagValue, Posthog -from posthog.emails import TRANSACTIONAL_CATEGORY, AsyncEmails, EmailCategory, Emails, SendEmailResult +from posthog.emails import TRANSACTIONAL_CATEGORY, AsyncEmails, Emails, MessageCategory, SendEmailResult from posthog.ai.evaluations import ( AsyncOfflineEvaluations, BooleanScorerConfig, @@ -43,9 +43,8 @@ _ = (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, category="product-updates", max_retries=0) -category: EmailCategory = TRANSACTIONAL_CATEGORY -transactional_send: SendEmailResult = emails.send(to="user@example.com", template_key="receipt", subject="Receipt", html="

Paid

", category=category) +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, category=MessageCategory(key="newsletter", type="marketing", name="Newsletter", description="Monthly product news"), max_retries=0) +transactional_send: SendEmailResult = emails.send(to="user@example.com", template_key="receipt", subject="Receipt", html="

Paid

", category=TRANSACTIONAL_CATEGORY) _ = (template_send, rendered_send, transactional_send) @@ -80,7 +79,7 @@ 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 +from posthog.emails import AsyncEmails, Emails, MessageCategory client = OfflineEvaluations(project_id=123, secret_key="phx_example") NumericPassingRule(operator="gt", threshold=0.8) # expected-type-error @@ -93,8 +92,9 @@ Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3 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

", 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 -Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

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

Hi

", category="newsletter") # expected-type-error +MessageCategory(key="newsletter", type="promo", name="Newsletter") # expected-type-error +Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", category="transactional") # expected-type-error async def invalid_async_email() -> None: diff --git a/.sampo/changesets/emails-send-category.md b/.sampo/changesets/emails-send-category.md index 8d151e7f4..92da803c1 100644 --- a/.sampo/changesets/emails-send-category.md +++ b/.sampo/changesets/emails-send-category.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out, or pass `TRANSACTIONAL_CATEGORY` (`"transactional"`), for transactional email, as before. Any other value is the key of one of the project's marketing message categories: the send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. `EmailCategory` types the value. PostHog answers a send it refuses with the error codes `unknown_category`, `unsubscribe_link_required` and `recipient_opted_out`. +`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out, or pass `TRANSACTIONAL_CATEGORY` (`"transactional"`), for transactional email with no category, as before. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project, and a send that changes its name or description updates it. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required` and `recipient_opted_out`. diff --git a/posthog/emails.py b/posthog/emails.py index c16b9c527..4e2b744c0 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -14,6 +14,7 @@ ```python from posthog import Posthog +from posthog.emails import MessageCategory posthog = Posthog("phc_...", secret_key="phs_...", host="https://eu.i.posthog.com") result = posthog.emails.send( @@ -34,23 +35,25 @@ distinct_id="user_123", ) -# Marketing email in one of the project's message categories, named by its key +# Marketing email in a message category that your code defines posthog.emails.send( to="user@example.com", template_key="november-digest", subject="What's new in November", html=digest_html, # links {{ unsubscribe_url }} - category="product-updates", + category=MessageCategory(key="newsletter", type="marketing", name="Newsletter"), distinct_id="user_123", ) ``` -Message categories: an email you send from code is transactional unless you -pass ``category``. Any value other than :data:`TRANSACTIONAL_CATEGORY` is the -key of one of the project's marketing message categories. A marketing send +Message categories: an email you send from code is transactional with no +category unless you pass ``category``. A :class:`MessageCategory` names the +category by its ``key``: the first send creates it in the project, and a send +that changes its ``name`` or ``description`` updates it. A marketing send honors the recipient's opt-outs, and its body must link ``{{ unsubscribe_url }}``, which PostHog fills with the recipient's -preferences link. +preferences link. A ``template_key`` that names a template made in PostHog +follows that template's category. ``posthog.emails`` above is the client's property. For async code, use ``AsyncPosthog`` or :class:`AsyncEmails` with ``posthog[async]`` installed. @@ -149,11 +152,11 @@ "TRANSACTIONAL_CATEGORY", "AsyncEmails", "CodeEmailArgs", - "EmailCategory", "EmailError", "EmailStatus", "EmailWarning", "Emails", + "MessageCategory", "SendEmailResult", "TemplateEmailArgs", ] @@ -161,7 +164,6 @@ log = logging.getLogger("posthog") TRANSACTIONAL_CATEGORY: Final = "transactional" -EmailCategory = Union[Literal["transactional"], str] _SEND_PATH = "/api/projects/@current/messaging/send/email/" _STATUS_PATH = "/api/projects/@current/messaging/emails/{message_id}/" @@ -222,6 +224,33 @@ class SendEmailResult: warnings: tuple[EmailWarning, ...] = () +@dataclass(frozen=True) +class MessageCategory: + """A message category of the project, defined in your code. + + A send creates the category when the project has no category with + ``key``, and updates its ``name`` and ``description`` when they changed. + An existing category must have the same ``type``. + + Args: + key: The category's key, unique in the project. Recipients' opt-outs + and preferences refer to it. + type: ``"marketing"`` honors the recipient's opt-outs and needs an + unsubscribe link. ``"transactional"`` files the email under the + category and skips opt-outs. + name: The name recipients see on their preferences page. + description: What the category's emails are about. + """ + + key: str + type: Literal["marketing", "transactional"] + name: str + description: Optional[str] = None + + +_CATEGORY_TYPES = frozenset({"marketing", "transactional"}) + + class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] @@ -272,15 +301,18 @@ class CodeEmailArgs(_RecipientArgs): template made in PostHog follows the project's setting, so the template form doesn't take it. category: Left out, or :data:`TRANSACTIONAL_CATEGORY` - (``"transactional"``), sends transactional email. Any other value - is the key of one of the project's marketing message categories. - A marketing send honors the recipient's opt-outs, and its ``html``, - or its ``text`` when it has no ``html``, must hold - ``{{ unsubscribe_url }}``. PostHog raises ``unknown_category`` for - a key the project has no marketing category for, - ``unsubscribe_link_required`` for a body without the link, and - ``recipient_opted_out`` for a recipient who opted out of the - category or of all marketing. + (``"transactional"``), sends transactional email with no category. + A :class:`MessageCategory` files the email under that category, and + creates or updates it. A marketing send honors the recipient's + opt-outs, and its ``html``, or its ``text`` when it has no + ``html``, must hold ``{{ unsubscribe_url }}``. PostHog raises + ``category_mismatch`` when the project's category with that key + has another type, or when ``template_key`` names a template in + another category; ``too_many_categories`` when the send would + create a category past the project's limit; + ``unsubscribe_link_required`` for a marketing body without the + link; and ``recipient_opted_out`` for a recipient who opted out + of the category or of all marketing. 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 @@ -298,7 +330,7 @@ class CodeEmailArgs(_RecipientArgs): from_: NotRequired[Optional[str]] reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] capture_engagement: NotRequired[Optional[bool]] - category: NotRequired[Optional[EmailCategory]] + category: NotRequired[Optional[Union[Literal["transactional"], MessageCategory]]] _OPTIONAL_CODE_FIELDS = frozenset( @@ -403,7 +435,7 @@ def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend: payload["distinct_id"] = distinct_id payload.update(_sender(args)) if "category" in args: - payload["category"] = _nonempty_text(args, "category") + payload["category"] = _category(args["category"]) return _PreparedSend( path=_SEND_PATH, body=_encode(payload), @@ -426,6 +458,21 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: return sender +def _category(category: Any) -> Union[str, dict[str, str]]: + if category == TRANSACTIONAL_CATEGORY: + return TRANSACTIONAL_CATEGORY + if not isinstance(category, MessageCategory): + raise TypeError("category must be TRANSACTIONAL_CATEGORY or a MessageCategory.") + if category.type not in _CATEGORY_TYPES: + raise ValueError("category.type must be 'marketing' or 'transactional'.") + fields = {"key": category.key, "type": category.type, "name": category.name} + if category.description is not None: + fields["description"] = category.description + if not all(isinstance(value, str) for value in fields.values()): + raise TypeError("category's key, name and description must be strings.") + return fields + + def _addresses(field: str, value: Any) -> list[str]: addresses = [value] if isinstance(value, str) else value if not isinstance(addresses, Sequence) or not all( diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index fbc86a89e..ba51108b4 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -20,6 +20,7 @@ EmailError, Emails, EmailStatus, + MessageCategory, ) from posthog.version import VERSION @@ -651,7 +652,12 @@ def send_code(emails: Emails, **overrides: Any): "from_": "Acme ", "reply_to": "help@example.com", "capture_engagement": False, - "category": "product-updates", + "category": MessageCategory( + key="product-updates", + type="marketing", + name="Product updates", + description="Monthly product news", + ), "distinct_id": "user_123", }, { @@ -660,10 +666,32 @@ def send_code(emails: Emails, **overrides: Any): "from": "Acme ", "reply_to": ["help@example.com"], "capture_engagement": False, - "category": "product-updates", + "category": { + "key": "product-updates", + "type": "marketing", + "name": "Product updates", + "description": "Monthly product news", + }, "distinct_id": "user_123", }, ), + ( + "category_without_description", + { + "html": HTML, + "category": MessageCategory( + key="receipts", type="transactional", name="Receipts" + ), + }, + { + "html": HTML, + "category": { + "key": "receipts", + "type": "transactional", + "name": "Receipts", + }, + }, + ), ( "transactional_category_sent_as_set", {"html": HTML, "category": TRANSACTIONAL_CATEGORY}, @@ -780,8 +808,32 @@ def handler(request: httpx.Request) -> httpx.Response: ("empty_html_and_text", {"html": "", "text": ""}, ValueError), ("blank_reply_to", {"html": HTML, "reply_to": " "}, ValueError), ("reply_to_not_text", {"html": HTML, "reply_to": 123}, TypeError), - ("category_not_text", {"html": HTML, "category": 1}, TypeError), - ("empty_category", {"html": HTML, "category": ""}, ValueError), + ("category_not_a_category", {"html": HTML, "category": 1}, TypeError), + ( + "category_as_a_key", + {"html": HTML, "category": "product-updates"}, + TypeError, + ), + ( + "category_type_unknown", + { + "html": HTML, + "category": MessageCategory( + key="product-updates", type="promo", name="Product updates" + ), + }, + ValueError, + ), + ( + "category_name_not_text", + { + "html": HTML, + "category": MessageCategory( + key="product-updates", type="marketing", name=None + ), + }, + TypeError, + ), ( "template_data_on_code_form", {"html": HTML, "template_data": {"first_name": "Jane"}}, @@ -804,7 +856,7 @@ def handler(request: httpx.Request) -> httpx.Response: "template_id": TEMPLATE_ID, "template_key": None, "subject": None, - "category": "product-updates", + "category": TRANSACTIONAL_CATEGORY, }, TypeError, ), diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 677d8476f..4f3904a84 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -889,14 +889,13 @@ attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]] -attribute posthog.emails.CodeEmailArgs.category: NotRequired[Optional[EmailCategory]] +attribute posthog.emails.CodeEmailArgs.category: NotRequired[Optional[Union[Literal['transactional'], MessageCategory]]] attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[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.template_key: str attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[str]] -attribute posthog.emails.EmailCategory = Union[Literal['transactional'], str] attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -914,6 +913,10 @@ attribute posthog.emails.EmailStatus.status_at: datetime attribute posthog.emails.EmailWarning.code: str attribute posthog.emails.EmailWarning.fix: Optional[str] = None attribute posthog.emails.EmailWarning.message: str +attribute posthog.emails.MessageCategory.description: Optional[str] = None +attribute posthog.emails.MessageCategory.key: str +attribute posthog.emails.MessageCategory.name: str +attribute posthog.emails.MessageCategory.type: Literal['marketing', 'transactional'] attribute posthog.emails.SendEmailResult.id: str attribute posthog.emails.SendEmailResult.replayed: bool = False attribute posthog.emails.SendEmailResult.warnings: tuple[EmailWarning, ...] = () @@ -1351,6 +1354,7 @@ class posthog.emails.EmailError(*, name: str, code: str, message: str, status_co class posthog.emails.EmailStatus(id: str, status: str, created_at: datetime, status_at: datetime, reason: Optional[str] = None) class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None) class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None, migrated_from: Optional[str] = None) +class posthog.emails.MessageCategory(key: str, type: Literal['marketing', 'transactional'], name: str, description: 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) From 073154c364fdab8e11e61ce564f5d993b236678b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:06:46 +0000 Subject: [PATCH 3/8] test(emails): send an empty category description to clear it The server keeps a category's description when the field is missing and clears it on an empty string, so the SDK sends "" as "" and leaves the field out only for None. Co-Authored-By: Claude Opus 5.5 --- posthog/emails.py | 3 ++- posthog/test/test_emails.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/posthog/emails.py b/posthog/emails.py index 4e2b744c0..8fe8e76ca 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -239,7 +239,8 @@ class MessageCategory: unsubscribe link. ``"transactional"`` files the email under the category and skips opt-outs. name: The name recipients see on their preferences page. - description: What the category's emails are about. + description: What the category's emails are about. ``None`` keeps + the category's stored description, and ``""`` clears it. """ key: str diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index ba51108b4..2cd933f38 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -692,6 +692,27 @@ def send_code(emails: Emails, **overrides: Any): }, }, ), + ( + "empty_category_description_sent_to_clear_it", + { + "html": HTML, + "category": MessageCategory( + key="newsletter", + type="marketing", + name="Newsletter", + description="", + ), + }, + { + "html": HTML, + "category": { + "key": "newsletter", + "type": "marketing", + "name": "Newsletter", + "description": "", + }, + }, + ), ( "transactional_category_sent_as_set", {"html": HTML, "category": TRANSACTIONAL_CATEGORY}, From 48361733b3a071777af863e3cf4b020ba84af0dc Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:13:08 +0000 Subject: [PATCH 4/8] test(emails): prove categories against a fake of the send API The category tests now send through an in-memory fake of PostHog's code send that refuses fields outside the contract's schema and upserts categories by key. They assert on the stored category and the recorded send instead of the request body. Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_emails.py | 224 +++++++++++++++++++++++++++--------- 1 file changed, 168 insertions(+), 56 deletions(-) diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 2cd933f38..6be5f4f58 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -3,6 +3,7 @@ import asyncio import json import logging +import re from datetime import datetime, timezone from decimal import Decimal from typing import Any, Optional, Union @@ -652,12 +653,6 @@ def send_code(emails: Emails, **overrides: Any): "from_": "Acme ", "reply_to": "help@example.com", "capture_engagement": False, - "category": MessageCategory( - key="product-updates", - type="marketing", - name="Product updates", - description="Monthly product news", - ), "distinct_id": "user_123", }, { @@ -666,58 +661,9 @@ def send_code(emails: Emails, **overrides: Any): "from": "Acme ", "reply_to": ["help@example.com"], "capture_engagement": False, - "category": { - "key": "product-updates", - "type": "marketing", - "name": "Product updates", - "description": "Monthly product news", - }, "distinct_id": "user_123", }, ), - ( - "category_without_description", - { - "html": HTML, - "category": MessageCategory( - key="receipts", type="transactional", name="Receipts" - ), - }, - { - "html": HTML, - "category": { - "key": "receipts", - "type": "transactional", - "name": "Receipts", - }, - }, - ), - ( - "empty_category_description_sent_to_clear_it", - { - "html": HTML, - "category": MessageCategory( - key="newsletter", - type="marketing", - name="Newsletter", - description="", - ), - }, - { - "html": HTML, - "category": { - "key": "newsletter", - "type": "marketing", - "name": "Newsletter", - "description": "", - }, - }, - ), - ( - "transactional_category_sent_as_set", - {"html": HTML, "category": TRANSACTIONAL_CATEGORY}, - {"html": HTML, "category": "transactional"}, - ), ( "several_reply_to", {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, @@ -736,7 +682,6 @@ def send_code(emails: Emails, **overrides: Any): "from_": None, "reply_to": None, "capture_engagement": None, - "category": None, "distinct_id": None, }, {"html": HTML}, @@ -805,6 +750,173 @@ def handler(request: httpx.Request) -> httpx.Response: } +_UNSUBSCRIBE_LINK = re.compile(r"\{\{\s*unsubscribe_url\s*\}\}") +_CODE_SEND_FIELDS = { + "to", + "template_key", + "subject", + "html", + "text", + "from", + "reply_to", + "capture_engagement", + "distinct_id", + "category", +} +_CATEGORY_FIELDS = {"key", "type", "name", "description"} + + +class FakeSendApi(requests.adapters.BaseAdapter): + def __init__(self, categories: Optional[dict[str, dict[str, str]]] = None): + super().__init__() + self.categories = categories or {} + self.sent: list[tuple[str, Optional[str]]] = [] + + def send( + self, request: requests.PreparedRequest, **kwargs: Any + ) -> requests.Response: + body = json.loads(request.body or b"{}") + status, reply = self._handle(body) + response = requests.Response() + response.status_code = status + response._content = json.dumps(reply).encode() + return response + + def _handle(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + category = body.get("category") + if set(body) - _CODE_SEND_FIELDS or not _is_valid_category(category): + return 400, {"code": "invalid_input", "attr": "category"} + if not isinstance(category, dict): + self.sent.append((body["to"], None)) + return 202, {"message_id": "msg_1"} + stored = self.categories.get(category["key"]) + if stored is not None and stored["type"] != category["type"]: + return 400, {"code": "category_mismatch", "attr": "category"} + if category["type"] == "marketing" and not _UNSUBSCRIBE_LINK.search( + body.get("html") or body.get("text") or "" + ): + return 400, {"code": "unsubscribe_link_required", "attr": "html"} + stored = stored or {"type": category["type"], "description": ""} + stored["name"] = category["name"] + if "description" in category: + stored["description"] = category["description"] + self.categories[category["key"]] = stored + self.sent.append((body["to"], category["key"])) + return 202, {"message_id": "msg_1"} + + def close(self) -> None: + pass + + +def _is_valid_category(category: Any) -> bool: + if category is None or category == "transactional": + return True + return ( + isinstance(category, dict) + and {"key", "type", "name"} <= set(category) <= _CATEGORY_FIELDS + and category["type"] in ("marketing", "transactional") + and all(isinstance(value, str) for value in category.values()) + ) + + +def emails_with_send_api( + categories: Optional[dict[str, dict[str, str]]] = None, +) -> tuple[Emails, FakeSendApi]: + api = FakeSendApi(categories) + session = requests.Session() + session.mount("https://", api) + return Emails(SECRET_KEY, session=session, max_retries=0), api + + +MARKETING_HTML = "

News. Unsubscribe

" +NEWSLETTER = MessageCategory( + key="newsletter", + type="marketing", + name="Newsletter", + description="Monthly product news", +) + + +def test_a_marketing_send_creates_its_category_in_the_project(): + emails, api = emails_with_send_api() + + send_code(emails, html=MARKETING_HTML, category=NEWSLETTER) + + assert api.categories == { + "newsletter": { + "type": "marketing", + "name": "Newsletter", + "description": "Monthly product news", + } + } + assert api.sent == [("user@example.com", "newsletter")] + + +@parameterized.expand( + [ + ("unset_keeps_it", None, "Old news"), + ("empty_clears_it", "", ""), + ("new_text_replaces_it", "Monthly product news", "Monthly product news"), + ] +) +def test_a_send_updates_its_category_description(_name, description, stored): + emails, api = emails_with_send_api( + {"newsletter": {"type": "marketing", "name": "News", "description": "Old news"}} + ) + + send_code( + emails, + html=MARKETING_HTML, + category=MessageCategory( + key="newsletter", + type="marketing", + name="Newsletter", + description=description, + ), + ) + + assert api.categories["newsletter"] == { + "type": "marketing", + "name": "Newsletter", + "description": stored, + } + + +@parameterized.expand( + [ + ("left_out", {}), + ("none", {"category": None}), + ("built_in_transactional", {"category": TRANSACTIONAL_CATEGORY}), + ] +) +def test_a_send_without_a_category_is_transactional(_name, overrides): + emails, api = emails_with_send_api() + + send_code(emails, html=HTML, **overrides) + + assert api.sent == [("user@example.com", None)] + assert api.categories == {} + + +def test_a_category_of_another_type_is_refused(): + emails, api = emails_with_send_api( + {"receipts": {"type": "transactional", "name": "Receipts", "description": ""}} + ) + + with pytest.raises(EmailError) as raised: + send_code( + emails, + html=MARKETING_HTML, + category=MessageCategory(key="receipts", type="marketing", name="Receipts"), + ) + + assert (raised.value.code, raised.value.name) == ( + "category_mismatch", + "validation_error", + ) + assert api.sent == [] + + @parameterized.expand( [ ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError), From 99d6d0e672c77dd9921bcefb39e429cbcb5fdd52 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:20:10 +0000 Subject: [PATCH 5/8] test(emails): prove a transactional category is created by its send Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_emails.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 6be5f4f58..662223559 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -837,19 +837,33 @@ def emails_with_send_api( ) -def test_a_marketing_send_creates_its_category_in_the_project(): +@parameterized.expand( + [ + ( + "marketing", + NEWSLETTER, + MARKETING_HTML, + { + "type": "marketing", + "name": "Newsletter", + "description": "Monthly product news", + }, + ), + ( + "transactional", + MessageCategory(key="receipts", type="transactional", name="Receipts"), + "

Your receipt

", + {"type": "transactional", "name": "Receipts", "description": ""}, + ), + ] +) +def test_a_send_creates_its_category_in_the_project(_name, category, html, stored): emails, api = emails_with_send_api() - send_code(emails, html=MARKETING_HTML, category=NEWSLETTER) + send_code(emails, html=html, category=category) - assert api.categories == { - "newsletter": { - "type": "marketing", - "name": "Newsletter", - "description": "Monthly product news", - } - } - assert api.sent == [("user@example.com", "newsletter")] + assert api.categories == {category.key: stored} + assert api.sent == [("user@example.com", category.key)] @parameterized.expand( From 8919451f0c1a15273cb5ef4a7e8e5fe3e5b71ca5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:36:36 +0000 Subject: [PATCH 6/8] test(emails): prove transactional is refused on a template in a category A code send whose template_key names a template follows that template's category when category is left out, and PostHog refuses an explicit TRANSACTIONAL_CATEGORY on a template in a category. The fake now models template-bound keys, and the docs no longer call the two the same. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-send-category.md | 2 +- posthog/emails.py | 10 ++-- posthog/test/test_emails.py | 69 ++++++++++++++++++++++- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.sampo/changesets/emails-send-category.md b/.sampo/changesets/emails-send-category.md index 92da803c1..91fffbd5c 100644 --- a/.sampo/changesets/emails-send-category.md +++ b/.sampo/changesets/emails-send-category.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out, or pass `TRANSACTIONAL_CATEGORY` (`"transactional"`), for transactional email with no category, as before. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project, and a send that changes its name or description updates it. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required` and `recipient_opted_out`. +`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. `TRANSACTIONAL_CATEGORY` (`"transactional"`) asks for transactional email with no category, so PostHog refuses it on a template in a category. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project, and a send that changes its name or description updates it. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required` and `recipient_opted_out`. diff --git a/posthog/emails.py b/posthog/emails.py index 8fe8e76ca..705e0034f 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -301,15 +301,17 @@ class CodeEmailArgs(_RecipientArgs): 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. - category: Left out, or :data:`TRANSACTIONAL_CATEGORY` - (``"transactional"``), sends transactional email with no category. - A :class:`MessageCategory` files the email under that category, and + category: Left out, the email follows the category of the template + that ``template_key`` names, or is transactional with no category. + :data:`TRANSACTIONAL_CATEGORY` (``"transactional"``) sends + transactional email with no category. A :class:`MessageCategory` files the email under that category, and creates or updates it. A marketing send honors the recipient's opt-outs, and its ``html``, or its ``text`` when it has no ``html``, must hold ``{{ unsubscribe_url }}``. PostHog raises ``category_mismatch`` when the project's category with that key has another type, or when ``template_key`` names a template in - another category; ``too_many_categories`` when the send would + another category, including :data:`TRANSACTIONAL_CATEGORY` for a + template in a category; ``too_many_categories`` when the send would create a category past the project's limit; ``unsubscribe_link_required`` for a marketing body without the link; and ``recipient_opted_out`` for a recipient who opted out diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index 662223559..f020f05fe 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -767,9 +767,14 @@ def handler(request: httpx.Request) -> httpx.Response: class FakeSendApi(requests.adapters.BaseAdapter): - def __init__(self, categories: Optional[dict[str, dict[str, str]]] = None): + def __init__( + self, + categories: Optional[dict[str, dict[str, str]]] = None, + templates: Optional[dict[str, Optional[str]]] = None, + ): super().__init__() self.categories = categories or {} + self.templates = templates or {} self.sent: list[tuple[str, Optional[str]]] = [] def send( @@ -786,6 +791,8 @@ def _handle(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: category = body.get("category") if set(body) - _CODE_SEND_FIELDS or not _is_valid_category(category): return 400, {"code": "invalid_input", "attr": "category"} + if body["template_key"] in self.templates: + return self._send_for_template(body, category) if not isinstance(category, dict): self.sent.append((body["to"], None)) return 202, {"message_id": "msg_1"} @@ -804,6 +811,23 @@ def _handle(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: self.sent.append((body["to"], category["key"])) return 202, {"message_id": "msg_1"} + def _send_for_template( + self, body: dict[str, Any], category: Any + ) -> tuple[int, dict[str, Any]]: + key = self.templates[body["template_key"]] + if key is None: + matches = category in (None, "transactional") + else: + matches = category is None or ( + isinstance(category, dict) + and category["key"] == key + and category["type"] == self.categories[key]["type"] + ) + if not matches: + return 400, {"code": "category_mismatch", "attr": "category"} + self.sent.append((body["to"], key)) + return 202, {"message_id": "msg_1"} + def close(self) -> None: pass @@ -821,8 +845,9 @@ def _is_valid_category(category: Any) -> bool: def emails_with_send_api( categories: Optional[dict[str, dict[str, str]]] = None, + templates: Optional[dict[str, Optional[str]]] = None, ) -> tuple[Emails, FakeSendApi]: - api = FakeSendApi(categories) + api = FakeSendApi(categories, templates) session = requests.Session() session.mount("https://", api) return Emails(SECRET_KEY, session=session, max_retries=0), api @@ -912,6 +937,46 @@ def test_a_send_without_a_category_is_transactional(_name, overrides): assert api.categories == {} +def emails_with_a_newsletter_template() -> tuple[Emails, FakeSendApi]: + return emails_with_send_api( + { + "newsletter": { + "type": "marketing", + "name": "Newsletter", + "description": "", + } + }, + templates={"november-digest": "newsletter"}, + ) + + +@parameterized.expand([("left_out", {}), ("none", {"category": None})]) +def test_a_send_for_a_marketing_template_follows_its_category(_name, overrides): + emails, api = emails_with_a_newsletter_template() + + send_code(emails, template_key="november-digest", html=MARKETING_HTML, **overrides) + + assert api.sent == [("user@example.com", "newsletter")] + + +def test_transactional_on_a_marketing_template_is_refused(): + emails, api = emails_with_a_newsletter_template() + + with pytest.raises(EmailError) as raised: + send_code( + emails, + template_key="november-digest", + html=MARKETING_HTML, + category=TRANSACTIONAL_CATEGORY, + ) + + assert (raised.value.code, raised.value.name) == ( + "category_mismatch", + "validation_error", + ) + assert api.sent == [] + + def test_a_category_of_another_type_is_refused(): emails, api = emails_with_send_api( {"receipts": {"type": "transactional", "name": "Receipts", "description": ""}} From d8667f64851a943504a553db8fc424deee2f0ba2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:27:47 +0000 Subject: [PATCH 7/8] fix(emails): say a send's category name and description apply only at creation A send that names a category now creates it when the project has none with that key, and otherwise leaves it as it is. Drop the update and the keep-or-clear description wording, name copies_not_allowed among the refusals, and prove a later send leaves the stored category unchanged. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-send-category.md | 2 +- posthog/emails.py | 34 ++++++++++-------- posthog/test/test_emails.py | 43 +++++++++++------------ 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/.sampo/changesets/emails-send-category.md b/.sampo/changesets/emails-send-category.md index 91fffbd5c..964bfe440 100644 --- a/.sampo/changesets/emails-send-category.md +++ b/.sampo/changesets/emails-send-category.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. `TRANSACTIONAL_CATEGORY` (`"transactional"`) asks for transactional email with no category, so PostHog refuses it on a template in a category. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project, and a send that changes its name or description updates it. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required` and `recipient_opted_out`. +`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. `TRANSACTIONAL_CATEGORY` (`"transactional"`) asks for transactional email with no category, so PostHog refuses it on a template in a category. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project with its name and description, and a later send leaves it as it is, so you change its name or description in PostHog. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required`, `copies_not_allowed` and `recipient_opted_out`. diff --git a/posthog/emails.py b/posthog/emails.py index 705e0034f..62ba2dae1 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -48,11 +48,11 @@ Message categories: an email you send from code is transactional with no category unless you pass ``category``. A :class:`MessageCategory` names the -category by its ``key``: the first send creates it in the project, and a send -that changes its ``name`` or ``description`` updates it. A marketing send -honors the recipient's opt-outs, and its body must link -``{{ unsubscribe_url }}``, which PostHog fills with the recipient's -preferences link. A ``template_key`` that names a template made in PostHog +category by its ``key``: the first send creates it in the project with its +``name`` and ``description``. A later send leaves the category as it is, so +change its name or description in PostHog. A marketing send honors the +recipient's opt-outs, and its body must link ``{{ unsubscribe_url }}``, +which PostHog fills with the recipient's preferences link. A ``template_key`` that names a template made in PostHog follows that template's category. ``posthog.emails`` above is the client's property. For async code, use @@ -229,8 +229,10 @@ class MessageCategory: """A message category of the project, defined in your code. A send creates the category when the project has no category with - ``key``, and updates its ``name`` and ``description`` when they changed. - An existing category must have the same ``type``. + ``key``, and restores it when it was deleted. Otherwise the send leaves + the category as it is: ``name`` and ``description`` apply only when the + send creates the category, and you change them later in PostHog. An + existing category must have the same ``type``. Args: key: The category's key, unique in the project. Recipients' opt-outs @@ -239,8 +241,7 @@ class MessageCategory: unsubscribe link. ``"transactional"`` files the email under the category and skips opt-outs. name: The name recipients see on their preferences page. - description: What the category's emails are about. ``None`` keeps - the category's stored description, and ``""`` clears it. + description: What the category's emails are about. """ key: str @@ -304,18 +305,21 @@ class CodeEmailArgs(_RecipientArgs): category: Left out, the email follows the category of the template that ``template_key`` names, or is transactional with no category. :data:`TRANSACTIONAL_CATEGORY` (``"transactional"``) sends - transactional email with no category. A :class:`MessageCategory` files the email under that category, and - creates or updates it. A marketing send honors the recipient's - opt-outs, and its ``html``, or its ``text`` when it has no - ``html``, must hold ``{{ unsubscribe_url }}``. PostHog raises + transactional email with no category. A + :class:`MessageCategory` files the email under that category, and + creates it when the project has none with its key. A marketing + send honors the recipient's opt-outs, and its ``html``, or its + ``text`` when it has no ``html``, must hold + ``{{ unsubscribe_url }}``. PostHog raises ``category_mismatch`` when the project's category with that key has another type, or when ``template_key`` names a template in another category, including :data:`TRANSACTIONAL_CATEGORY` for a template in a category; ``too_many_categories`` when the send would create a category past the project's limit; ``unsubscribe_link_required`` for a marketing body without the - link; and ``recipient_opted_out`` for a recipient who opted out - of the category or of all marketing. + link; ``copies_not_allowed`` for a marketing email with copies; + and ``recipient_opted_out`` for a recipient who opted out of the + category or of all marketing. 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 diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index f020f05fe..a6f1a41a3 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -803,11 +803,12 @@ def _handle(self, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: body.get("html") or body.get("text") or "" ): return 400, {"code": "unsubscribe_link_required", "attr": "html"} - stored = stored or {"type": category["type"], "description": ""} - stored["name"] = category["name"] - if "description" in category: - stored["description"] = category["description"] - self.categories[category["key"]] = stored + if stored is None: + self.categories[category["key"]] = { + "type": category["type"], + "name": category["name"], + "description": category.get("description", ""), + } self.sent.append((body["to"], category["key"])) return 202, {"message_id": "msg_1"} @@ -891,17 +892,9 @@ def test_a_send_creates_its_category_in_the_project(_name, category, html, store assert api.sent == [("user@example.com", category.key)] -@parameterized.expand( - [ - ("unset_keeps_it", None, "Old news"), - ("empty_clears_it", "", ""), - ("new_text_replaces_it", "Monthly product news", "Monthly product news"), - ] -) -def test_a_send_updates_its_category_description(_name, description, stored): - emails, api = emails_with_send_api( - {"newsletter": {"type": "marketing", "name": "News", "description": "Old news"}} - ) +def test_a_send_leaves_a_category_it_created_as_it_is(): + emails, api = emails_with_send_api() + send_code(emails, html=MARKETING_HTML, category=NEWSLETTER) send_code( emails, @@ -909,16 +902,22 @@ def test_a_send_updates_its_category_description(_name, description, stored): category=MessageCategory( key="newsletter", type="marketing", - name="Newsletter", - description=description, + name="Product news", + description="Weekly tips", ), ) - assert api.categories["newsletter"] == { - "type": "marketing", - "name": "Newsletter", - "description": stored, + assert api.categories == { + "newsletter": { + "type": "marketing", + "name": "Newsletter", + "description": "Monthly product news", + } } + assert api.sent == [ + ("user@example.com", "newsletter"), + ("user@example.com", "newsletter"), + ] @parameterized.expand( From 8590215574cebc4cc29b0b93f7457c84f6fe2e69 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:43:25 +0000 Subject: [PATCH 8/8] fix(emails): say a template made in PostHog is transactional only A template in a marketing category is refused with marketing_template, so the docs and changeset name that code, and the test fake seeds a template in a transactional category for follow-the-template and category_mismatch. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-send-category.md | 2 +- posthog/emails.py | 9 +++-- posthog/test/test_emails.py | 44 ++++++++++++++++------- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.sampo/changesets/emails-send-category.md b/.sampo/changesets/emails-send-category.md index 964bfe440..2f8086dfd 100644 --- a/.sampo/changesets/emails-send-category.md +++ b/.sampo/changesets/emails-send-category.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. `TRANSACTIONAL_CATEGORY` (`"transactional"`) asks for transactional email with no category, so PostHog refuses it on a template in a category. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project with its name and description, and a later send leaves it as it is, so you change its name or description in PostHog. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required`, `copies_not_allowed` and `recipient_opted_out`. +`emails.send` takes an optional `category` on the code form, the one with `template_key`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. A template made in PostHog is transactional, so PostHog refuses a `template_key` that names a template in a marketing category with `marketing_template`. `TRANSACTIONAL_CATEGORY` (`"transactional"`) asks for transactional email with no category, so PostHog refuses it on a template in a category. Pass a `MessageCategory` with a `key`, a `type` (`"marketing"` or `"transactional"`), a `name` and an optional `description` to file the email under that category: the first send creates it in the project with its name and description, and a later send leaves it as it is, so you change its name or description in PostHog. A marketing send honors the recipient's opt-outs, and its body must link `{{ unsubscribe_url }}`. PostHog refuses a send with the error codes `category_mismatch`, `too_many_categories`, `unsubscribe_link_required`, `copies_not_allowed`, `recipient_opted_out` and `marketing_template`. diff --git a/posthog/emails.py b/posthog/emails.py index 62ba2dae1..819ae6deb 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -52,8 +52,10 @@ ``name`` and ``description``. A later send leaves the category as it is, so change its name or description in PostHog. A marketing send honors the recipient's opt-outs, and its body must link ``{{ unsubscribe_url }}``, -which PostHog fills with the recipient's preferences link. A ``template_key`` that names a template made in PostHog -follows that template's category. +which PostHog fills with the recipient's preferences link. A ``template_key`` +that names a template made in PostHog follows that template's category. A +template made in PostHog is transactional: PostHog refuses one in a marketing +category with ``marketing_template``. ``posthog.emails`` above is the client's property. For async code, use ``AsyncPosthog`` or :class:`AsyncEmails` with ``posthog[async]`` installed. @@ -304,6 +306,9 @@ class CodeEmailArgs(_RecipientArgs): template form doesn't take it. category: Left out, the email follows the category of the template that ``template_key`` names, or is transactional with no category. + A template made in PostHog is transactional, so PostHog raises + ``marketing_template`` for a ``template_key`` that names a template + in a marketing category. :data:`TRANSACTIONAL_CATEGORY` (``"transactional"``) sends transactional email with no category. A :class:`MessageCategory` files the email under that category, and diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index a6f1a41a3..35cf7734a 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -816,6 +816,8 @@ def _send_for_template( self, body: dict[str, Any], category: Any ) -> tuple[int, dict[str, Any]]: key = self.templates[body["template_key"]] + if key is not None and self.categories[key]["type"] == "marketing": + return 400, {"code": "marketing_template"} if key is None: matches = category in (None, "transactional") else: @@ -936,36 +938,36 @@ def test_a_send_without_a_category_is_transactional(_name, overrides): assert api.categories == {} -def emails_with_a_newsletter_template() -> tuple[Emails, FakeSendApi]: +def emails_with_a_receipt_template() -> tuple[Emails, FakeSendApi]: return emails_with_send_api( { - "newsletter": { - "type": "marketing", - "name": "Newsletter", + "receipts": { + "type": "transactional", + "name": "Receipts", "description": "", } }, - templates={"november-digest": "newsletter"}, + templates={"order-receipt": "receipts"}, ) @parameterized.expand([("left_out", {}), ("none", {"category": None})]) -def test_a_send_for_a_marketing_template_follows_its_category(_name, overrides): - emails, api = emails_with_a_newsletter_template() +def test_a_send_for_a_template_in_a_category_follows_its_category(_name, overrides): + emails, api = emails_with_a_receipt_template() - send_code(emails, template_key="november-digest", html=MARKETING_HTML, **overrides) + send_code(emails, template_key="order-receipt", html=HTML, **overrides) - assert api.sent == [("user@example.com", "newsletter")] + assert api.sent == [("user@example.com", "receipts")] -def test_transactional_on_a_marketing_template_is_refused(): - emails, api = emails_with_a_newsletter_template() +def test_transactional_on_a_template_in_a_category_is_refused(): + emails, api = emails_with_a_receipt_template() with pytest.raises(EmailError) as raised: send_code( emails, - template_key="november-digest", - html=MARKETING_HTML, + template_key="order-receipt", + html=HTML, category=TRANSACTIONAL_CATEGORY, ) @@ -976,6 +978,22 @@ def test_transactional_on_a_marketing_template_is_refused(): assert api.sent == [] +def test_a_send_for_a_marketing_template_is_refused(): + emails, api = emails_with_send_api( + {"newsletter": {"type": "marketing", "name": "Newsletter", "description": ""}}, + templates={"november-digest": "newsletter"}, + ) + + with pytest.raises(EmailError) as raised: + send_code(emails, template_key="november-digest", html=MARKETING_HTML) + + assert (raised.value.code, raised.value.name) == ( + "marketing_template", + "validation_error", + ) + assert api.sent == [] + + def test_a_category_of_another_type_is_refused(): emails, api = emails_with_send_api( {"receipts": {"type": "transactional", "name": "Receipts", "description": ""}}