Skip to content
Draft
12 changes: 8 additions & 4 deletions .github/scripts/check_strict_types.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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="<p>Reset</p>", from_="Acme <security@example.com>", 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="<p>Reset</p>", from_="Acme <security@example.com>", 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="<p>Paid</p>", category=TRANSACTIONAL_CATEGORY)
_ = (template_send, rendered_send, transactional_send)


async def async_email_types() -> None:
Expand Down Expand Up @@ -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
Expand All @@ -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="<p>Hi</p>", template_data={"name": "Ada"}) # expected-type-error
Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="<p>Hi</p>", cc="team@example.com") # expected-type-error
Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="<p>Hi</p>", 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:
Expand Down
5 changes: 5 additions & 0 deletions .sampo/changesets/emails-send-category.md
Original file line number Diff line number Diff line change
@@ -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`.
99 changes: 98 additions & 1 deletion posthog/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -103,6 +125,8 @@
from typing import (
Any,
Callable,
Final,
Literal,
Mapping,
Optional,
Sequence,
Expand All @@ -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}")
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -273,6 +352,7 @@ class CodeEmailArgs(_RecipientArgs):
"from_",
"reply_to",
"capture_engagement",
"category",
}
)

Expand Down Expand Up @@ -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),
Expand All @@ -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
Comment thread
Silthus marked this conversation as resolved.
Resolved


def _addresses(field: str, value: Any) -> list[str]:
addresses = [value] if isinstance(value, str) else value
if not isinstance(addresses, Sequence) or not all(
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading