diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 4e41c9e8f..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 AsyncEmails, Emails, SendEmailResult +from posthog.emails import TRANSACTIONAL_CATEGORY, AsyncEmails, Emails, MessageCategory, SendEmailResult from posthog.ai.evaluations import ( AsyncOfflineEvaluations, BooleanScorerConfig, @@ -43,8 +43,9 @@ _ = (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_="AcmeReset
", from_="AcmePaid
", category=TRANSACTIONAL_CATEGORY) +_ = (template_send, rendered_send, transactional_send) async def async_email_types() -> None: @@ -78,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 @@ -91,6 +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="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 new file mode 100644 index 000000000..2f8086dfd --- /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 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 c53528c7c..819ae6deb 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( @@ -33,8 +34,29 @@ html=rendered_html, distinct_id="user_123", ) + +# 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=MessageCategory(key="newsletter", type="marketing", name="Newsletter"), + distinct_id="user_123", +) ``` +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 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. 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. @@ -103,6 +125,8 @@ from typing import ( Any, Callable, + Final, + Literal, Mapping, Optional, Sequence, @@ -127,18 +151,22 @@ from .version import VERSION __all__ = [ + "TRANSACTIONAL_CATEGORY", "AsyncEmails", "CodeEmailArgs", "EmailError", "EmailStatus", "EmailWarning", "Emails", + "MessageCategory", "SendEmailResult", "TemplateEmailArgs", ] log = logging.getLogger("posthog") +TRANSACTIONAL_CATEGORY: Final = "transactional" + _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}") @@ -198,6 +226,35 @@ 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 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 + 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]] @@ -247,6 +304,27 @@ 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, 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 + 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; ``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 @@ -264,6 +342,7 @@ class CodeEmailArgs(_RecipientArgs): from_: NotRequired[Optional[str]] reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] capture_engagement: NotRequired[Optional[bool]] + category: NotRequired[Optional[Union[Literal["transactional"], MessageCategory]]] _OPTIONAL_CODE_FIELDS = frozenset( @@ -273,6 +352,7 @@ class CodeEmailArgs(_RecipientArgs): "from_", "reply_to", "capture_engagement", + "category", } ) @@ -366,6 +446,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"] = _category(args["category"]) return _PreparedSend( path=_SEND_PATH, body=_encode(payload), @@ -388,6 +470,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( @@ -541,7 +638,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..35cf7734a 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 @@ -14,7 +15,14 @@ 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, + MessageCategory, +) from posthog.version import VERSION SECRET_KEY = "phs_test_secret" @@ -742,6 +750,269 @@ 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, + 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( + 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 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"} + 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"} + 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"} + + 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: + 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 + + +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, + templates: Optional[dict[str, Optional[str]]] = None, +) -> tuple[Emails, FakeSendApi]: + api = FakeSendApi(categories, templates) + 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", +) + + +@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=html, category=category) + + assert api.categories == {category.key: stored} + assert api.sent == [("user@example.com", category.key)] + + +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, + html=MARKETING_HTML, + category=MessageCategory( + key="newsletter", + type="marketing", + name="Product news", + description="Weekly tips", + ), + ) + + 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( + [ + ("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 emails_with_a_receipt_template() -> tuple[Emails, FakeSendApi]: + return emails_with_send_api( + { + "receipts": { + "type": "transactional", + "name": "Receipts", + "description": "", + } + }, + templates={"order-receipt": "receipts"}, + ) + + +@parameterized.expand([("left_out", {}), ("none", {"category": None})]) +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="order-receipt", html=HTML, **overrides) + + assert api.sent == [("user@example.com", "receipts")] + + +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="order-receipt", + html=HTML, + category=TRANSACTIONAL_CATEGORY, + ) + + assert (raised.value.code, raised.value.name) == ( + "category_mismatch", + "validation_error", + ) + 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": ""}} + ) + + 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), @@ -766,6 +1037,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_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"}}, @@ -782,6 +1079,16 @@ def handler(request: httpx.Request) -> httpx.Response: }, TypeError, ), + ( + "category_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "category": TRANSACTIONAL_CATEGORY, + }, + 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..4f3904a84 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -889,6 +889,7 @@ 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[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]]]] @@ -912,9 +913,14 @@ 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, ...] = () +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 @@ -1348,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)