diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh
index 7b46c0af0..7703ab786 100755
--- a/.github/scripts/check_strict_types.sh
+++ b/.github/scripts/check_strict_types.sh
@@ -15,6 +15,7 @@ import atexit
import posthog
from posthog import FeatureFlagEvaluations, FlagValue, Posthog
+from posthog.emails import AsyncEmails, Emails, SendEmailResult
from posthog.ai.evaluations import (
AsyncOfflineEvaluations,
BooleanScorerConfig,
@@ -40,6 +41,18 @@ span.end()
_ = (flag_value, all_flags, enabled, payload, evaluations, active)
+emails = Emails("phs_example", migrated_from="resend")
+template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}, capture_engagement=False, max_retries=0)
+rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="
Reset
", from_="Acme ", reply_to=["help@example.com"])
+local_template_send: SendEmailResult = emails.send(to="user@example.com", template_key="welcome", subject="Hi {{ variables.name }}", text="Hi", template_data={"name": "Ada"})
+invite_send: SendEmailResult = emails.send(to="user@example.com", template_key="invite", subject="Invite", html="Invite
", cc="team@example.com", bcc=["audit@example.com"], headers={"X-Entity-Ref-ID": "order-123"}, tags=[{"name": "category", "value": "invite"}], attachments=[{"filename": "invite.ics", "content": b"BEGIN:VCALENDAR", "content_type": "text/calendar"}, {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}])
+_ = (template_send, rendered_send, local_template_send, invite_send)
+
+
+async def async_email_types() -> None:
+ sent: SendEmailResult = await AsyncEmails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="Hi
")
+ _ = sent
+
async def offline_evaluation_types(
sync_client: OfflineEvaluations, async_client: AsyncOfflineEvaluations
@@ -64,15 +77,24 @@ async def offline_evaluation_types(
_ = config
PY
-cat > "$tmp/invalid_offline_types.py" <<'PY'
+cat > "$tmp/invalid_types.py" <<'PY'
# pyright: strict
from posthog.ai.evaluations import BooleanScorerConfig, CategoricalScorerConfig, NumericPassingRule, NumericScorerConfig, OfflineEvaluations
+from posthog.emails import AsyncEmails, Emails
client = OfflineEvaluations(project_id=123, secret_key="phx_example")
NumericPassingRule(operator="gt", threshold=0.8) # expected-type-error
NumericScorerConfig(unknown_setting=True) # expected-type-error
CategoricalScorerConfig(selection_mode="single") # expected-type-error
client.scorers.create(name="Bad config", kind="numeric", config=BooleanScorerConfig(true_is_failure=True)) # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_key="welcome", subject="Hi", html="Hi
") # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_key="welcome", html="Hi
") # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", cc="team@example.com") # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="Hi
", attachments=[{"filename": "a.txt", "content": [104, 105]}]) # expected-type-error
+
+
+async def invalid_async_email() -> None:
+ await AsyncEmails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", subject="Hi") # expected-type-error
PY
"$tmp/.venv/bin/python" - <<'PY' > "$tmp/public_api_access.py"
@@ -116,8 +138,8 @@ JSON
cd "$tmp"
"$tmp/.venv/bin/python" -m pyright strict_posthog_types.py public_api_access.py
-if "$tmp/.venv/bin/python" -m pyright --outputjson invalid_offline_types.py > invalid_types.json; then
- echo "Invalid offline evaluation configurations unexpectedly passed type checking."
+if "$tmp/.venv/bin/python" -m pyright --outputjson invalid_types.py > invalid_types.json; then
+ echo "Invalid offline evaluation configurations and email sends unexpectedly passed type checking."
exit 1
fi
"$tmp/.venv/bin/python" - <<'PY'
@@ -125,11 +147,11 @@ import json
from pathlib import Path
expected = {
- index for index, line in enumerate(Path("invalid_offline_types.py").read_text().splitlines())
+ index for index, line in enumerate(Path("invalid_types.py").read_text().splitlines())
if "expected-type-error" in line
}
diagnostics = json.loads(Path("invalid_types.json").read_text())["generalDiagnostics"]
actual = {item["range"]["start"]["line"] for item in diagnostics if item["severity"] == "error"}
assert actual == expected, diagnostics
-print("Offline evaluation configuration types reject invalid inputs.")
+print("Offline evaluation configuration and email send types reject invalid inputs.")
PY
diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md
new file mode 100644
index 000000000..f9b8d0ac4
--- /dev/null
+++ b/.sampo/changesets/emails-code-form.md
@@ -0,0 +1,7 @@
+---
+pypi/posthog: minor
+---
+
+Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `EmailValidationError`.
+
+A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, `bytearray` or `memoryview`, which the client encodes as Base64, or a Base64 string.
diff --git a/.sampo/changesets/emails-contract-alignment.md b/.sampo/changesets/emails-contract-alignment.md
new file mode 100644
index 000000000..bd8cb2bb8
--- /dev/null
+++ b/.sampo/changesets/emails-contract-alignment.md
@@ -0,0 +1,5 @@
+---
+pypi/posthog: minor
+---
+
+`emails.send` follows the shared email SDK contract. An argument that fails a check raises `EmailValidationError`, an `EmailError` and a `ValueError` with the check's `code` and the argument in `field`. A disabled client raises `client_disabled`, and a client without a usable app host raises `missing_app_host`. A send takes `max_retries`, and a template send takes `capture_engagement`. Set `migrated_from`, such as `"resend"`, on the client to send it with every email request. A `Retry-After` over 30 seconds waits 30 seconds instead of failing the send. `SendEmailResult` has no `replayed` field.
diff --git a/posthog/async_client.py b/posthog/async_client.py
index 74f2d1533..e0f4fdcb1 100644
--- a/posthog/async_client.py
+++ b/posthog/async_client.py
@@ -133,6 +133,7 @@ def __init__(
app_host: Optional[str] = None,
email_request_timeout_seconds: float = 15,
email_request_max_retries: int = 2,
+ migrated_from: Optional[str] = None,
) -> None:
if flush_at <= 0:
raise ValueError("flush_at must be greater than zero")
@@ -177,10 +178,13 @@ def __init__(
else resolved_secret_key
) or None
self.personal_api_key = self.secret_key
- _validate_options(email_request_timeout_seconds, email_request_max_retries)
+ _validate_options(
+ email_request_timeout_seconds, email_request_max_retries, migrated_from
+ )
self.app_host = app_host
self.email_request_timeout_seconds = email_request_timeout_seconds
self.email_request_max_retries = email_request_max_retries
+ self.migrated_from = migrated_from
self.feature_flags_request_timeout_seconds = (
feature_flags_request_timeout_seconds
)
@@ -260,13 +264,14 @@ def __init__(
@property
def emails(self) -> AsyncEmails:
"""
- Send transactional email from PostHog email templates.
+ Send transactional email from PostHog email templates or from content in your code.
Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog
Cloud app host of ``host``. ``email_request_timeout_seconds`` and
``email_request_max_retries`` configure each send. A disabled or shut-down
- client sends nothing. See ``posthog.emails`` for setup.
+ client sends nothing and raises ``client_disabled`` or ``client_closed``.
+ See ``posthog.emails`` for setup.
Examples:
```python
@@ -287,6 +292,7 @@ def emails(self) -> AsyncEmails:
app_host=self.app_host,
timeout=self.email_request_timeout_seconds,
max_retries=self.email_request_max_retries,
+ migrated_from=self.migrated_from,
)
return self._emails
diff --git a/posthog/client.py b/posthog/client.py
index fc1368256..47c515136 100644
--- a/posthog/client.py
+++ b/posthog/client.py
@@ -725,6 +725,7 @@ def __init__(
app_host: Optional[str] = None,
email_request_timeout_seconds: float = 15,
email_request_max_retries: int = 2,
+ migrated_from: Optional[str] = None,
):
"""
Initialize a new PostHog client instance.
@@ -785,6 +786,8 @@ def __init__(
email_request_max_retries: Number of retries for an email send after a
network error, a rate limit, or an unavailable service. Defaults to 2.
Set to 0 when a job runner already retries the send.
+ migrated_from: The email provider you moved from, such as
+ ``"resend"``. ``emails`` sends it with every request.
super_properties: Properties merged into every captured event.
enable_exception_autocapture: Automatically capture uncaught
exceptions.
@@ -1049,10 +1052,13 @@ def __init__(
else resolved_secret_key
) or None
self.personal_api_key = self.secret_key
- _validate_options(email_request_timeout_seconds, email_request_max_retries)
+ _validate_options(
+ email_request_timeout_seconds, email_request_max_retries, migrated_from
+ )
self.app_host = app_host
self.email_request_timeout_seconds = email_request_timeout_seconds
self.email_request_max_retries = email_request_max_retries
+ self.migrated_from = migrated_from
if debug:
# Ensures that debug level messages are logged when debug mode is on.
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
@@ -1152,13 +1158,14 @@ def _request_identity_kwargs(self) -> Dict[str, str]:
@property
def emails(self) -> Emails:
"""
- Send transactional email from PostHog email templates.
+ Send transactional email from PostHog email templates or from content in your code.
Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope. Sends go to ``app_host``, or to the PostHog Cloud app host of
``host``. ``email_request_timeout_seconds`` and ``email_request_max_retries``
- configure each send. A disabled or shut-down client sends nothing. See
- ``posthog.emails`` for setup.
+ configure each send. A disabled or shut-down client sends nothing and
+ raises ``client_disabled`` or ``client_closed``. See ``posthog.emails``
+ for setup.
Examples:
```python
@@ -1180,6 +1187,7 @@ def emails(self) -> Emails:
app_host=self.app_host,
timeout=self.email_request_timeout_seconds,
max_retries=self.email_request_max_retries,
+ migrated_from=self.migrated_from,
)
return self._emails
diff --git a/posthog/emails.py b/posthog/emails.py
index d6d66560a..ad292ccb1 100644
--- a/posthog/emails.py
+++ b/posthog/emails.py
@@ -1,4 +1,4 @@
-"""Transactional email, sent from an email template in PostHog.
+"""Transactional email, sent from an email template in PostHog or from content in your code.
Setup:
@@ -22,6 +22,26 @@
idempotency_key="welcome:user_123",
)
print(result.id) # the queued email
+
+# Content you rendered for this recipient, named by a key
+posthog.emails.send(
+ to="user@example.com",
+ template_key="password-reset",
+ subject="Reset your password",
+ html=rendered_html,
+ distinct_id="user_123",
+)
+
+# A template in your code. PostHog fills in the variables, and the client
+# uploads the template whenever its content changes.
+posthog.emails.send(
+ to="user@example.com",
+ template_key="welcome",
+ subject="Welcome, {{ variables.name }}",
+ html=welcome_html,
+ template_data={"name": "Ada"},
+ distinct_id="user_123",
+)
```
``posthog.emails`` above is the client's property. For async code, use
@@ -37,36 +57,50 @@
Timeouts and retries: each request times out after 15 seconds. A send retries
network errors, rate limits and an unavailable service twice, waiting about 1
-and then 2 seconds, or the server's ``Retry-After`` if that is longer. A
-``Retry-After`` over 30 seconds raises at once. Every attempt sends the same
-idempotency key, so a retry never sends twice. Set
-``email_request_max_retries=0`` on the client, or ``max_retries=0`` on
+and then 2 seconds, or the server's ``Retry-After`` if that is longer, at most
+30 seconds. Every attempt sends the same idempotency key, so a retry never
+sends twice. Pass ``max_retries=0`` to ``send()``, or set
+``email_request_max_retries=0`` on the client or ``max_retries=0`` on
:class:`Emails`, when a job runner already retries the send, and pass an
``idempotency_key`` that names the email, such as ``welcome:user_123``.
+Moving from another email provider: set ``migrated_from``, such as
+``"resend"``, on the client or on :class:`Emails`, so PostHog knows where your
+sends came from.
+
Errors: a send that is not queued raises :class:`EmailError`. Branch on
``code``. The ones a setup usually hits:
- ``missing_secret_key``: the client has no ``secret_key``.
-- ``app_host_required``: the client's ``host`` is not PostHog Cloud. Set ``app_host``.
+- ``missing_app_host``: the client's ``host`` is not PostHog Cloud. Set ``app_host``.
- ``authentication_failed`` (401) or ``permission_denied`` (403): the key is
wrong, or it lacks ``hog_flow:write``.
- ``not_found`` (404): the template ID is wrong, or transactional email is not
- enabled for the project.
+ enabled for the project. A template send with ``capture_engagement`` gets
+ ``template_not_found`` for a wrong ID instead, and ``not_found`` from a
+ PostHog version without the email send route.
+- ``client_disabled``: the client is disabled.
- ``client_closed``: the client is shut down.
+An argument that fails a check raises :class:`EmailValidationError` before any
+request. It is an :class:`EmailError` and a ``ValueError``, its ``code`` is
+``invalid_form``, ``invalid_template_id``, ``invalid_template_key`` or
+``invalid_idempotency_key``, and ``field`` names the argument.
+
Cleanup: ``shutdown()`` on the client closes its email connections. Close a
standalone :class:`Emails` with ``close()`` or a ``with`` block.
Testing without network calls: pass your own transport, ``session`` to
:class:`Emails` or ``http_client`` to :class:`AsyncEmails`, with a fake adapter
or ``httpx.MockTransport``. Any library that mocks ``requests`` or ``httpx``
-works too. A disabled client returns a result with ``id=None`` and sends nothing.
+works too. A disabled client sends nothing and raises ``client_disabled``.
"""
from __future__ import annotations
import asyncio
+import base64
+import hashlib
import importlib
import json
import logging
@@ -75,12 +109,23 @@
import re
import threading
import time
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from types import TracebackType
-from typing import Any, Callable, Mapping, Optional, TypeVar, Union
+from typing import (
+ Any,
+ Callable,
+ Mapping,
+ Optional,
+ Sequence,
+ TypedDict,
+ TypeVar,
+ Union,
+ overload,
+)
from uuid import UUID, uuid4
import requests
+from typing_extensions import NotRequired, Unpack
from .capture_v1 import _parse_retry_after
from .request import (
@@ -92,13 +137,29 @@
from .utils import clean
from .version import VERSION
-__all__ = ["AsyncEmails", "EmailError", "EmailWarning", "Emails", "SendEmailResult"]
+__all__ = [
+ "AsyncEmails",
+ "CodeEmailArgs",
+ "EmailAttachment",
+ "EmailError",
+ "EmailTag",
+ "EmailValidationError",
+ "EmailWarning",
+ "Emails",
+ "SendEmailResult",
+ "TemplateEmailArgs",
+]
log = logging.getLogger("posthog")
_SEND_PATH = "/api/projects/@current/messaging_templates/{template_id}/send/"
+_CODE_SEND_PATH = "/api/projects/@current/messaging/send/email/"
+_UPLOAD_PATH = "/api/projects/@current/messaging_templates/by_key/{template_key}/"
+_TEMPLATE_KEY = re.compile(r"[a-zA-Z0-9_-]{1,400}")
+_CONTENT_HASH_HEADER = b"posthog-email-template-v1\n"
_MAX_RETRY_DELAY = 30.0
-_IDEMPOTENCY_KEY = re.compile(r"^[\x21-\x7e]{1,255}$")
+_IDEMPOTENCY_KEY = re.compile(r"[\x21-\x7e]{1,255}")
+_HEADER_TOKEN = re.compile(r"[\x21-\x7e]+")
_IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"})
_QUOTA_CODES = frozenset({"quota_limited"})
@@ -136,6 +197,24 @@ def __init__(
self.retry_after = retry_after
+class EmailValidationError(EmailError, ValueError):
+ """An argument failed a check before any request, so nothing was sent.
+
+ ``name`` is ``validation_error``. ``code`` says which check failed, such as
+ ``invalid_form`` or ``invalid_template_key``, and ``field`` names the
+ argument when there is one.
+ """
+
+ def __init__(self, *, code: str, message: str, field: Optional[str]) -> None:
+ super().__init__(
+ name="validation_error", code=code, message=message, field=field
+ )
+
+
+def _invalid(field: Optional[str], message: str) -> EmailValidationError:
+ return EmailValidationError(code="invalid_form", message=message, field=field)
+
+
@dataclass(frozen=True)
class EmailWarning:
code: str
@@ -145,51 +224,366 @@ class EmailWarning:
@dataclass(frozen=True)
class SendEmailResult:
- """The queued email. ``id`` is ``None`` when the client is disabled and sent nothing."""
+ """The queued email."""
- id: Optional[str]
- replayed: bool = False
+ id: str
warnings: tuple[EmailWarning, ...] = ()
+class EmailTag(TypedDict):
+ """A name and value that PostHog adds to the email's engagement events as ``$email_tags``."""
+
+ name: str
+ value: str
+
+
+class EmailAttachment(TypedDict):
+ """A file sent with the email.
+
+ Args:
+ filename: The file name the recipient sees, such as ``invite.ics``.
+ content: The file as ``bytes``, ``bytearray`` or ``memoryview``, or
+ already Base64 encoded as a string.
+ content_type: The MIME type. Without it, the type follows the file name.
+ content_id: Shows the file inline: reference it in ``html`` as
+ ``
`` with ``content_id`` ``logo``.
+ """
+
+ filename: str
+ content: Union[bytes, bytearray, memoryview, str]
+ content_type: NotRequired[str]
+ content_id: NotRequired[str]
+
+
+class _RecipientArgs(TypedDict):
+ to: str
+ distinct_id: NotRequired[Optional[str]]
+ idempotency_key: NotRequired[Optional[str]]
+ max_retries: NotRequired[int]
+
+
+class TemplateEmailArgs(_RecipientArgs):
+ """A send of an email template made in PostHog, named by its ID.
+
+ Args:
+ to: One recipient email address.
+ template_id: The UUID of a template made transactional in PostHog.
+ template_data: Values for the template's ``{{ variables.* }}``
+ placeholders.
+ distinct_id: The recipient's distinct ID in your product.
+ idempotency_key: 1 to 255 visible ASCII characters.
+ max_retries: Retries for this send, instead of the client's.
+ capture_engagement: Capture opens and clicks. Without it, the
+ project's setting applies. Setting it needs a PostHog version with
+ the email send route, ``/messaging/send/email/``.
+ """
+
+ template_id: Union[str, UUID]
+ template_data: NotRequired[Optional[Mapping[str, Any]]]
+ capture_engagement: NotRequired[bool]
+
+
+class CodeEmailArgs(_RecipientArgs):
+ """A send of email content that lives in your code, named by a key.
+
+ Without ``template_data``, the content is rendered for this one recipient
+ and sent as is. With it, the content is a template: PostHog fills in its
+ ``{{ variables.* }}`` placeholders, and the client uploads the template
+ whenever PostHog doesn't hold its content yet.
+
+ A PostHog version that doesn't take a field yet, such as ``cc``, refuses
+ the send with ``invalid_request`` and names the field in
+ ``EmailError.field``. It never drops the field and sends the rest.
+
+ Args:
+ to: One recipient email address.
+ template_key: Names the email, so its metrics, logs and status group
+ under it. Letters, digits, ``-`` and ``_``, at most 400.
+ subject: The subject line.
+ html: The HTML body. Give ``html``, ``text`` or both.
+ text: The plain-text body. Without it, PostHog derives it from
+ ``html``. HTML with no text in it, such as a single image, goes out
+ without a plain-text part.
+ from_: A verified sender, such as ``"Acme "``.
+ reply_to: One address or a list of addresses.
+ cc: One address or a list of addresses that get a visible copy.
+ bcc: One address or a list of addresses that get a hidden copy.
+ headers: Extra email headers by name, such as ``X-Entity-Ref-ID``.
+ tags: Names and values that PostHog adds to the email's engagement
+ events.
+ attachments: Files sent with the email.
+ capture_engagement: Capture opens and clicks. Without it, PostHog's
+ default applies, which is on.
+ template_data: Values for the ``{{ variables.* }}`` placeholders.
+ distinct_id: The recipient's distinct ID in your product.
+ idempotency_key: 1 to 255 visible ASCII characters. Template sends
+ and code sends share one set of keys: a key already used for
+ another email, or for a ``template_id`` send, raises
+ ``idempotency_key_reused``. A retry with the same key returns the
+ first send's result, also when its workflow was paused since.
+ max_retries: Retries for this send, instead of the client's.
+ """
+
+ template_key: str
+ subject: str
+ html: NotRequired[str]
+ text: NotRequired[str]
+ from_: NotRequired[str]
+ reply_to: NotRequired[Union[str, Sequence[str]]]
+ cc: NotRequired[Union[str, Sequence[str]]]
+ bcc: NotRequired[Union[str, Sequence[str]]]
+ headers: NotRequired[Mapping[str, str]]
+ tags: NotRequired[Sequence[EmailTag]]
+ attachments: NotRequired[Sequence[EmailAttachment]]
+ capture_engagement: NotRequired[bool]
+ template_data: NotRequired[Mapping[str, Any]]
+
+
+@dataclass(frozen=True)
+class _PreparedUpload:
+ path: str
+ body: bytes
+
+
@dataclass(frozen=True)
class _PreparedSend:
path: str
body: bytes
idempotency_key: str
+ upload: Optional[_PreparedUpload] = None
+ max_retries: Optional[int] = None
+
+
+def _prepare(args: Mapping[str, Any]) -> _PreparedSend:
+ is_template_form = "template_id" in args
+ if is_template_form == ("template_key" in args):
+ raise _invalid(None, "Pass either template_id or template_key.")
+ form = TemplateEmailArgs if is_template_form else CodeEmailArgs
+ unexpected = sorted(set(args) - set(form.__annotations__))
+ if unexpected:
+ raise _invalid(
+ unexpected[0],
+ f"send() got arguments that the {'template' if is_template_form else 'code'} "
+ f"form doesn't take: {', '.join(unexpected)}.",
+ )
+ if "to" not in args:
+ raise _invalid("to", "send() is missing the argument to.")
+ prepared = _prepare_send(args) if is_template_form else _prepare_code_send(args)
+ if "max_retries" not in args:
+ return prepared
+ if not _is_retry_count(args["max_retries"]):
+ raise _invalid("max_retries", "max_retries must be a nonnegative integer.")
+ return replace(prepared, max_retries=args["max_retries"])
-def _prepare_send(
- to: str,
- template_id: Union[str, UUID],
- template_data: Optional[Mapping[str, Any]],
- distinct_id: Optional[str],
- idempotency_key: Optional[str],
-) -> _PreparedSend:
- if not isinstance(to, str):
- raise TypeError("to must be one email address as a string.")
- if not to.strip():
- raise ValueError("to must not be empty.")
+def _is_retry_count(value: Any) -> bool:
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
+
+
+def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend:
+ if "subject" not in args:
+ raise _invalid("subject", "send() is missing the argument subject.")
+ to, distinct_id, idempotency_key = _recipient(
+ args["to"], args.get("distinct_id"), args.get("idempotency_key")
+ )
+ template_key = args["template_key"]
+ if not isinstance(template_key, str) or not _TEMPLATE_KEY.fullmatch(template_key):
+ raise EmailValidationError(
+ code="invalid_template_key",
+ message="template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.",
+ field="template_key",
+ )
+ content = {"subject": _nonempty_text(args, "subject")}
+ content.update(
+ {field: _string(args, field) for field in ("html", "text") if field in args}
+ )
+ if "html" not in content and "text" not in content:
+ raise _invalid("html", "send() needs html, text or both.")
+ if not (content.get("html") or content.get("text")):
+ raise _invalid("html", "html or text must be a nonempty string.")
+
+ payload: dict[str, Any] = {"to": to}
+ upload = None
+ if "template_data" in args:
+ template_data = args["template_data"]
+ if not isinstance(template_data, Mapping):
+ raise _invalid("template_data", "template_data must be a mapping.")
+ payload["template"] = {"key": template_key, "hash": _content_hash(content)}
+ payload["message_data"] = clean(dict(template_data))
+ upload = _PreparedUpload(
+ path=_UPLOAD_PATH.format(template_key=template_key),
+ body=_encode(
+ {
+ "name": template_key,
+ **content,
+ "if_not_superseded": True,
+ "if_code_managed": True,
+ }
+ ),
+ )
+ else:
+ payload.update({"template_key": template_key, **content})
+ payload.update(_sender(args))
+ if distinct_id is not None:
+ payload["identifiers"] = {"distinct_id": distinct_id}
+ return _PreparedSend(
+ path=_CODE_SEND_PATH,
+ body=_encode(payload),
+ idempotency_key=idempotency_key,
+ upload=upload,
+ )
+
+
+def _content_hash(content: Mapping[str, str]) -> str:
+ """PostHog's content hash v1 of a template, so a send can say which content it holds."""
+ encoded = bytearray(_CONTENT_HASH_HEADER)
+ for field in ("subject", "preheader", "html", "text"):
+ value = content.get(field, "").replace("\r\n", "\n").replace("\r", "\n")
+ data = value.encode("utf-8")
+ encoded += f"{len(data)}:".encode() + data + b"\n"
+ return "v1:" + hashlib.sha256(encoded).hexdigest()
+
+
+def _sender(args: Mapping[str, Any]) -> dict[str, Any]:
+ sender: dict[str, Any] = {}
+ if "from_" in args:
+ sender["from"] = _nonempty_text(args, "from_")
+ for field in ("reply_to", "cc", "bcc"):
+ if field in args:
+ sender[field] = _addresses(field, args[field])
+ if "headers" in args:
+ sender["headers"] = _headers_field(args["headers"])
+ if "tags" in args:
+ sender["tags"] = _tags(args["tags"])
+ if "attachments" in args:
+ sender["attachments"] = _attachments(args["attachments"])
+ sender.update(_capture_engagement(args))
+ return sender
+
+
+def _capture_engagement(args: Mapping[str, Any]) -> dict[str, bool]:
+ if "capture_engagement" not in args:
+ return {}
+ if not isinstance(args["capture_engagement"], bool):
+ raise _invalid(
+ "capture_engagement", "capture_engagement must be True or False."
+ )
+ return {"capture_engagement": args["capture_engagement"]}
+
+
+def _addresses(field: str, value: Any) -> list[str]:
+ addresses = [value] if isinstance(value, str) else value
+ if not isinstance(addresses, Sequence) or not all(
+ isinstance(address, str) for address in addresses
+ ):
+ raise _invalid(field, f"{field} must be one address or a list of addresses.")
+ if not all(address.strip() for address in addresses):
+ raise _invalid(field, f"{field} must not hold a blank address.")
+ return list(addresses)
+
+
+def _headers_field(headers: Any) -> dict[str, str]:
+ if not isinstance(headers, Mapping) or not all(
+ isinstance(name, str) and isinstance(value, str)
+ for name, value in headers.items()
+ ):
+ raise _invalid("headers", "headers must map header names to string values.")
+ return dict(headers)
+
+
+def _tags(tags: Any) -> list[dict[str, str]]:
+ if (
+ isinstance(tags, str)
+ or not isinstance(tags, Sequence)
+ or not all(_is_tag(tag) for tag in tags)
+ ):
+ raise _invalid("tags", "tags must be a list of {'name': str, 'value': str}.")
+ return [{"name": tag["name"], "value": tag["value"]} for tag in tags]
+
+
+def _attachments(attachments: Any) -> list[dict[str, str]]:
+ if isinstance(attachments, (str, bytes)) or not isinstance(attachments, Sequence):
+ raise _invalid("attachments", "attachments must be a list of files.")
+ return [_attachment(index, file) for index, file in enumerate(attachments)]
+
+
+def _attachment(index: int, file: Any) -> dict[str, str]:
+ name = f"attachments[{index}]"
+ if not isinstance(file, Mapping) or not {"filename", "content"} <= set(file):
+ raise _invalid("attachments", f"{name} needs a filename and its content.")
+ unexpected = set(file) - set(EmailAttachment.__annotations__)
+ if unexpected:
+ raise _invalid(
+ "attachments",
+ f"{name} has fields an attachment doesn't take: {', '.join(sorted(unexpected))}.",
+ )
+ if not all(isinstance(file[field], str) for field in file if field != "content"):
+ raise _invalid(
+ "attachments",
+ f"{name} needs its filename, content_type and content_id as strings.",
+ )
+ return {**file, "content": _base64_content(name, file["content"])}
+
+
+def _base64_content(name: str, content: Any) -> str:
+ if isinstance(content, (bytes, bytearray, memoryview)):
+ return base64.b64encode(bytes(content)).decode("ascii")
+ if isinstance(content, str):
+ return content
+ raise _invalid(
+ "attachments",
+ f"{name}.content must be bytes or a Base64 string. "
+ "Convert a list of byte values with bytes(content).",
+ )
+
+
+def _is_tag(tag: Any) -> bool:
+ return (
+ isinstance(tag, Mapping)
+ and set(tag) == {"name", "value"}
+ and isinstance(tag["name"], str)
+ and isinstance(tag["value"], str)
+ )
+
+
+def _string(args: Mapping[str, Any], name: str) -> str:
+ value = args[name]
+ if not isinstance(value, str):
+ raise _invalid(name, f"{name} must be a string.")
+ return value
+
+
+def _nonempty_text(args: Mapping[str, Any], name: str) -> str:
+ value = _string(args, name)
+ if not value:
+ raise _invalid(name, f"{name} must not be empty.")
+ return value
+
+
+def _encode(payload: Mapping[str, Any]) -> bytes:
+ return json.dumps(
+ payload, cls=DatetimeSerializer, separators=(",", ":"), ensure_ascii=False
+ ).encode("utf-8")
+
+
+def _prepare_send(args: Mapping[str, Any]) -> _PreparedSend:
+ to, distinct_id, idempotency_key = _recipient(
+ args["to"], args.get("distinct_id"), args.get("idempotency_key")
+ )
+ template_id = args["template_id"]
+ template_data = args.get("template_data")
try:
template_uuid = (
template_id if isinstance(template_id, UUID) else UUID(template_id)
)
except (TypeError, ValueError, AttributeError):
- raise ValueError("template_id must be the template's UUID.") from None
+ raise EmailValidationError(
+ code="invalid_template_id",
+ message="template_id must be the template's UUID.",
+ field="template_id",
+ ) from None
if template_data is not None and not isinstance(template_data, Mapping):
- raise TypeError("template_data must be a mapping.")
- if distinct_id is not None and (
- not isinstance(distinct_id, str) or not distinct_id
- ):
- raise ValueError("distinct_id must be a nonempty string.")
- if idempotency_key is None:
- idempotency_key = str(uuid4())
- elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.match(
- idempotency_key
- ):
- raise ValueError(
- "idempotency_key must be 1 to 255 visible ASCII characters without spaces."
- )
+ raise _invalid("template_data", "template_data must be a mapping.")
payload: dict[str, Any] = {
"to": to,
@@ -197,6 +591,16 @@ def _prepare_send(
}
if distinct_id is not None:
payload["identifiers"] = {"distinct_id": distinct_id}
+ tracking = _capture_engagement(args)
+ if tracking:
+ # The template route drops fields it doesn't know, so only the email send route can take this one.
+ return _PreparedSend(
+ path=_CODE_SEND_PATH,
+ body=_encode(
+ {"template": {"id": str(template_uuid)}, **payload, **tracking}
+ ),
+ idempotency_key=idempotency_key,
+ )
return _PreparedSend(
path=_SEND_PATH.format(template_id=template_uuid),
body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"),
@@ -204,7 +608,33 @@ def _prepare_send(
)
-def _validate_options(timeout: float, max_retries: int) -> None:
+def _recipient(
+ to: Any, distinct_id: Any, idempotency_key: Any
+) -> tuple[str, Optional[str], str]:
+ if not isinstance(to, str):
+ raise _invalid("to", "to must be one email address as a string.")
+ if not to.strip():
+ raise _invalid("to", "to must not be empty.")
+ if distinct_id is not None and (
+ not isinstance(distinct_id, str) or not distinct_id
+ ):
+ raise _invalid("distinct_id", "distinct_id must be a nonempty string.")
+ if idempotency_key is None:
+ idempotency_key = str(uuid4())
+ elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch(
+ idempotency_key
+ ):
+ raise EmailValidationError(
+ code="invalid_idempotency_key",
+ message="idempotency_key must be 1 to 255 visible ASCII characters without spaces.",
+ field="idempotency_key",
+ )
+ return to, distinct_id, idempotency_key
+
+
+def _validate_options(
+ timeout: float, max_retries: int, migrated_from: Optional[str]
+) -> None:
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
@@ -212,12 +642,14 @@ def _validate_options(timeout: float, max_retries: int) -> None:
or timeout <= 0
):
raise ValueError("timeout must be a finite positive number of seconds.")
- if (
- isinstance(max_retries, bool)
- or not isinstance(max_retries, int)
- or max_retries < 0
- ):
+ if not _is_retry_count(max_retries):
raise ValueError("max_retries must be a nonnegative integer.")
+ if migrated_from is not None and not (
+ isinstance(migrated_from, str) and _HEADER_TOKEN.fullmatch(migrated_from)
+ ):
+ raise ValueError(
+ "migrated_from must be visible ASCII characters, such as 'resend'."
+ )
def _text(value: Any) -> Optional[str]:
@@ -237,9 +669,9 @@ def _error_name(status: Optional[int], code: str) -> str:
return "not_found"
if status == 409:
return (
- "validation_error"
- if code == "template_outdated"
- else "concurrent_idempotent_requests"
+ "concurrent_idempotent_requests"
+ if code in _IN_FLIGHT_CODES
+ else "validation_error"
)
if status == 422:
return "invalid_idempotent_request"
@@ -278,7 +710,6 @@ def _parse_response(
warnings = fields.get("warnings")
return SendEmailResult(
id=message_id,
- replayed=fields.get("replayed") is True,
warnings=tuple(
EmailWarning(
code=_text(warning.get("code")) or "warning",
@@ -291,8 +722,20 @@ def _parse_response(
if isinstance(warning, dict)
),
)
+ raise _api_error(status, fields, retry_after_header)
+
+
+def _parse_upload(status: int, payload: Any, retry_after_header: Optional[str]) -> None:
+ if not 200 <= status < 300:
+ fields = payload if isinstance(payload, dict) else {}
+ raise _api_error(status, fields, retry_after_header)
+
+
+def _api_error(
+ status: int, fields: Mapping[str, Any], retry_after_header: Optional[str]
+) -> EmailError:
code = _text(fields.get("code")) or "api_error"
- raise EmailError(
+ return EmailError(
name=_error_name(status, code),
code=code,
message=_text(fields.get("detail")) or f"PostHog answered HTTP {status}.",
@@ -319,35 +762,51 @@ def _is_retryable(error: EmailError) -> bool:
def _retry_delay(error: EmailError, attempt: int, max_retries: int) -> Optional[float]:
if attempt >= max_retries or not _is_retryable(error):
return None
- # Retrying before a longer Retry-After would break the server's rate limit.
- if error.retry_after is not None and error.retry_after > _MAX_RETRY_DELAY:
- return None
- backoff = min(_MAX_RETRY_DELAY, 2.0**attempt * random.uniform(0.8, 1.2))
- return max(backoff, error.retry_after or 0.0)
+ backoff = 2.0**attempt * random.uniform(0.8, 1.2)
+ return min(_MAX_RETRY_DELAY, max(backoff, error.retry_after or 0.0))
def _no_op() -> None:
pass
+def _is_outdated(error: EmailError) -> bool:
+ return error.status_code == 409 and error.code == "template_outdated"
+
+
def _log_warnings(result: SendEmailResult) -> None:
for warning in result.warnings:
log.warning("PostHog email warning %s: %s", warning.code, warning.message)
-def _headers(secret_key: str, idempotency_key: str) -> dict[str, str]:
- return {
+def _headers(
+ secret_key: str, idempotency_key: Optional[str], migrated_from: Optional[str]
+) -> dict[str, str]:
+ headers = {
"Authorization": f"Bearer {secret_key}",
"Accept": "application/json",
"Content-Type": "application/json",
- "Idempotency-Key": idempotency_key,
"User-Agent": f"posthog-python/{VERSION}",
}
+ if idempotency_key is not None:
+ headers["Idempotency-Key"] = idempotency_key
+ if migrated_from is not None:
+ headers["X-PostHog-Migrated-From"] = migrated_from
+ return headers
+
+
+_ResultT = TypeVar("_ResultT")
+_Parse = Callable[[int, Any, Optional[str]], _ResultT]
class _BearerAuth(requests.auth.AuthBase):
- def __init__(self, secret_key: str, idempotency_key: str) -> None:
- self._headers = _headers(secret_key, idempotency_key)
+ def __init__(
+ self,
+ secret_key: str,
+ idempotency_key: Optional[str],
+ migrated_from: Optional[str],
+ ) -> None:
+ self._headers = _headers(secret_key, idempotency_key, migrated_from)
def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
# Setting auth explicitly stops requests from substituting .netrc credentials.
@@ -364,13 +823,15 @@ def __init__(
timeout: float,
max_retries: int,
disabled: bool,
+ migrated_from: Optional[str],
) -> None:
- _validate_options(timeout, max_retries)
+ _validate_options(timeout, max_retries, migrated_from)
self.secret_key = secret_key.strip() if isinstance(secret_key, str) else None
self.host = determine_app_host(host)
self.timeout = timeout
self.max_retries = max_retries
self.disabled = disabled
+ self.migrated_from = migrated_from
self._parent_disabled: Callable[[], bool] = lambda: False
self._admit: Callable[[], Optional[Callable[[], None]]] = lambda: _no_op
self._host_error: Optional[str] = None
@@ -379,6 +840,11 @@ def _is_disabled(self) -> bool:
return self.disabled or self._parent_disabled()
def _start_send(self) -> Callable[[], None]:
+ if self._is_disabled():
+ raise _sdk_error(
+ "client_disabled",
+ "The PostHog client is disabled, so no email was sent.",
+ )
finish = self._admit()
if finish is None:
raise _sdk_error(
@@ -387,9 +853,14 @@ def _start_send(self) -> Callable[[], None]:
)
return finish
+ def _retries(self, prepared: _PreparedSend) -> int:
+ return (
+ self.max_retries if prepared.max_retries is None else prepared.max_retries
+ )
+
def _secret_key(self) -> str:
if self._host_error:
- raise _sdk_error("app_host_required", self._host_error)
+ raise _sdk_error("missing_app_host", self._host_error)
if not self.secret_key:
raise _sdk_error(
"missing_secret_key",
@@ -399,7 +870,7 @@ def _secret_key(self) -> str:
class Emails(_EmailsBase):
- """Sends transactional email from PostHog email templates.
+ """Sends transactional email from PostHog email templates or from content in your code.
``host`` is your PostHog app host. PostHog Cloud ingestion hosts map to the
app host, so ``us.i.posthog.com`` sends to ``us.posthog.com``. Any other
@@ -409,7 +880,8 @@ class Emails(_EmailsBase):
to ``max_retries`` times, with the same idempotency key. Set
``max_retries=0`` when a job runner already retries the send. ``timeout``
is in seconds, per request. Pass ``session`` to send through your own
- ``requests.Session``, for example a test double.
+ ``requests.Session``, for example a test double. ``migrated_from``, such
+ as ``"resend"``, tells PostHog which provider your sends came from.
"""
def __init__(
@@ -421,6 +893,7 @@ def __init__(
max_retries: int = 2,
disabled: bool = False,
session: Optional[requests.Session] = None,
+ migrated_from: Optional[str] = None,
) -> None:
super().__init__(
secret_key,
@@ -428,21 +901,25 @@ def __init__(
timeout=timeout,
max_retries=max_retries,
disabled=disabled,
+ migrated_from=migrated_from,
)
self._session: Optional[requests.Session] = session
self._owns_session = session is None
self._session_lock = threading.Lock()
- def send(
- self,
- *,
- to: str,
- template_id: Union[str, UUID],
- template_data: Optional[Mapping[str, Any]] = None,
- distinct_id: Optional[str] = None,
- idempotency_key: Optional[str] = None,
- ) -> SendEmailResult:
- """Queue one email from a transactional email template.
+ @overload
+ def send(self, **kwargs: Unpack[TemplateEmailArgs]) -> SendEmailResult: ...
+
+ @overload
+ def send(self, **kwargs: Unpack[CodeEmailArgs]) -> SendEmailResult: ...
+
+ def send(self, **kwargs: Any) -> SendEmailResult:
+ """Queue one email, from a template in PostHog or from content in your code.
+
+ Pass exactly one form. The template form names a template made in
+ PostHog with ``template_id``. The code form names the email with
+ ``template_key`` and passes ``subject`` and ``html``, ``text`` or both:
+ see :class:`CodeEmailArgs`.
Args:
to: One recipient email address.
@@ -455,10 +932,15 @@ def send(
person whose distinct ID is the email address.
idempotency_key: 1 to 255 visible ASCII characters. A send with a
key that PostHog has already accepted queues nothing and returns
- the first send's result. Without it, the client makes a key
- that covers its own retries. Pass a key derived from what the
- email is for, such as ``welcome:user_123``, to cover retries
- outside this call, too.
+ the first send's result, when the other arguments are the same.
+ With other arguments, it raises ``idempotency_key_reused``.
+ Without it, the client makes a key that covers its own retries.
+ Pass a key derived from what the email is for, such as
+ ``welcome:user_123``, to cover retries outside this call, too.
+ max_retries: Retries for this send, instead of the client's.
+ ``0`` turns them off.
+ capture_engagement: Capture opens and clicks, instead of the
+ project's setting.
Returns:
The queued email. A result means PostHog queued the email, not that
@@ -467,7 +949,8 @@ def send(
Raises:
EmailError: PostHog did not queue the email, or the client could
not tell whether it did.
- TypeError, ValueError: An argument has the wrong type or format.
+ EmailValidationError: An argument has the wrong type or format.
+ Nothing was sent.
Examples:
```python
@@ -478,14 +961,16 @@ def send(
distinct_id="user_123",
idempotency_key="welcome:user_123",
)
+ emails.send(
+ to="user@example.com",
+ template_key="password-reset",
+ subject="Reset your password",
+ html=rendered_html,
+ distinct_id="user_123",
+ )
```
"""
- prepared = _prepare_send(
- to, template_id, template_data, distinct_id, idempotency_key
- )
- if self._is_disabled():
- log.debug("PostHog client is disabled, so no email was sent.")
- return SendEmailResult(id=None)
+ prepared = _prepare(kwargs)
finish = self._start_send()
try:
return self._deliver(prepared)
@@ -493,15 +978,47 @@ def send(
finish()
def _deliver(self, prepared: _PreparedSend) -> SendEmailResult:
- auth = _BearerAuth(self._secret_key(), prepared.idempotency_key)
+ secret_key = self._secret_key()
+ retries = self._retries(prepared)
+ auth = _BearerAuth(secret_key, prepared.idempotency_key, self.migrated_from)
+ try:
+ result = self._request(
+ "POST", prepared.path, prepared.body, auth, retries, _parse_response
+ )
+ except EmailError as error:
+ if prepared.upload is None or not _is_outdated(error):
+ raise
+ self._request(
+ "PUT",
+ prepared.upload.path,
+ prepared.upload.body,
+ _BearerAuth(secret_key, None, self.migrated_from),
+ retries,
+ _parse_upload,
+ )
+ result = self._request(
+ "POST", prepared.path, prepared.body, auth, retries, _parse_response
+ )
+ _log_warnings(result)
+ return result
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ body: bytes,
+ auth: _BearerAuth,
+ max_retries: int,
+ parse: _Parse[_ResultT],
+ ) -> _ResultT:
session = self._get_session()
- url = self.host + prepared.path
- for attempt in range(self.max_retries + 1):
+ url = self.host + path
+ for attempt in range(max_retries + 1):
try:
response = session.request(
- "POST",
+ method,
url,
- data=prepared.body,
+ data=body,
auth=auth,
timeout=self.timeout,
allow_redirects=False,
@@ -511,20 +1028,18 @@ def _deliver(self, prepared: _PreparedSend) -> SendEmailResult:
payload = response.json()
except ValueError:
payload = None
- result = _parse_response(
+ return parse(
response.status_code,
payload,
response.headers.get("Retry-After"),
)
finally:
response.close()
- _log_warnings(result)
- return result
except requests.exceptions.RequestException:
error = _sdk_error("network_error", "The request to PostHog failed.")
except EmailError as exc:
error = exc
- delay = _retry_delay(error, attempt, self.max_retries)
+ delay = _retry_delay(error, attempt, max_retries)
if delay is None:
raise error from None
time.sleep(delay)
@@ -574,6 +1089,7 @@ def __init__(
max_retries: int = 2,
disabled: bool = False,
http_client: Any = None,
+ migrated_from: Optional[str] = None,
) -> None:
super().__init__(
secret_key,
@@ -581,20 +1097,19 @@ def __init__(
timeout=timeout,
max_retries=max_retries,
disabled=disabled,
+ migrated_from=migrated_from,
)
self._client: Any = http_client
self._owns_client = http_client is None
- async def send(
- self,
- *,
- to: str,
- template_id: Union[str, UUID],
- template_data: Optional[Mapping[str, Any]] = None,
- distinct_id: Optional[str] = None,
- idempotency_key: Optional[str] = None,
- ) -> SendEmailResult:
- """Queue one email from a transactional email template.
+ @overload
+ async def send(self, **kwargs: Unpack[TemplateEmailArgs]) -> SendEmailResult: ...
+
+ @overload
+ async def send(self, **kwargs: Unpack[CodeEmailArgs]) -> SendEmailResult: ...
+
+ async def send(self, **kwargs: Any) -> SendEmailResult:
+ """Queue one email, from a template in PostHog or from content in your code.
Takes the same arguments and raises the same errors as
:meth:`Emails.send`.
@@ -609,12 +1124,7 @@ async def send(
)
```
"""
- prepared = _prepare_send(
- to, template_id, template_data, distinct_id, idempotency_key
- )
- if self._is_disabled():
- log.debug("PostHog client is disabled, so no email was sent.")
- return SendEmailResult(id=None)
+ prepared = _prepare(kwargs)
finish = self._start_send()
try:
return await self._deliver(prepared)
@@ -622,33 +1132,63 @@ async def send(
finish()
async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult:
- headers = _headers(self._secret_key(), prepared.idempotency_key)
+ secret_key = self._secret_key()
+ retries = self._retries(prepared)
+ headers = _headers(secret_key, prepared.idempotency_key, self.migrated_from)
+ try:
+ result = await self._request(
+ "POST", prepared.path, prepared.body, headers, retries, _parse_response
+ )
+ except EmailError as error:
+ if prepared.upload is None or not _is_outdated(error):
+ raise
+ await self._request(
+ "PUT",
+ prepared.upload.path,
+ prepared.upload.body,
+ _headers(secret_key, None, self.migrated_from),
+ retries,
+ _parse_upload,
+ )
+ result = await self._request(
+ "POST", prepared.path, prepared.body, headers, retries, _parse_response
+ )
+ _log_warnings(result)
+ return result
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ body: bytes,
+ headers: Mapping[str, str],
+ max_retries: int,
+ parse: _Parse[_ResultT],
+ ) -> _ResultT:
httpx, client = self._get_client()
- url = self.host + prepared.path
- for attempt in range(self.max_retries + 1):
+ url = self.host + path
+ for attempt in range(max_retries + 1):
try:
- response = await client.post(
- url, content=prepared.body, headers=headers
+ response = await client.request(
+ method, url, content=body, headers=headers
)
try:
try:
payload = response.json()
except ValueError:
payload = None
- result = _parse_response(
+ return parse(
response.status_code,
payload,
response.headers.get("Retry-After"),
)
finally:
await response.aclose()
- _log_warnings(result)
- return result
except httpx.RequestError:
error = _sdk_error("network_error", "The request to PostHog failed.")
except EmailError as exc:
error = exc
- delay = _retry_delay(error, attempt, self.max_retries)
+ delay = _retry_delay(error, attempt, max_retries)
if delay is None:
raise error from None
await asyncio.sleep(delay)
@@ -698,10 +1238,15 @@ def _client_emails(
app_host: Optional[str],
timeout: float,
max_retries: int,
+ migrated_from: Optional[str],
) -> _EmailsT:
if app_host:
emails = factory(
- secret_key, host=app_host, timeout=timeout, max_retries=max_retries
+ secret_key,
+ host=app_host,
+ timeout=timeout,
+ max_retries=max_retries,
+ migrated_from=migrated_from,
)
emails._parent_disabled = is_disabled
emails._admit = admit
@@ -713,6 +1258,7 @@ def _client_emails(
host=inferred_host if is_cloud else None,
timeout=timeout,
max_retries=max_retries,
+ migrated_from=migrated_from,
)
if not is_cloud:
# A custom host is often an ingestion proxy, which must not receive the secret key.
diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py
index 077fdcce0..4ca2c145c 100644
--- a/posthog/test/test_emails.py
+++ b/posthog/test/test_emails.py
@@ -14,7 +14,7 @@
from parameterized import parameterized
from posthog import AsyncPosthog, Posthog
-from posthog.emails import AsyncEmails, EmailError, Emails
+from posthog.emails import AsyncEmails, EmailError, Emails, EmailValidationError
from posthog.version import VERSION
SECRET_KEY = "phs_test_secret"
@@ -68,13 +68,6 @@ def emails_with(replies: list[Reply], **options: Any) -> tuple[Emails, FakeAdapt
return Emails(SECRET_KEY, session=session, **options), adapter
-def response(status: int, body: dict[str, Any]) -> requests.Response:
- reply = requests.Response()
- reply.status_code = status
- reply._content = json.dumps(body).encode()
- return reply
-
-
def send(emails: Emails, **overrides: Any):
return emails.send(
**{"to": "user@example.com", "template_id": TEMPLATE_ID, **overrides}
@@ -114,12 +107,12 @@ def test_send_posts_the_template_to_the_app_host(_name, overrides, extra_body):
)
assert result.id == "msg_1"
- assert result.replayed is False
[request] = adapter.requests
assert (request.method, request.url) == ("POST", SEND_URL)
assert request.headers["Authorization"] == f"Bearer {SECRET_KEY}"
assert request.headers["Idempotency-Key"] == "welcome:user_123"
assert request.headers["User-Agent"] == f"posthog-python/{VERSION}"
+ assert "X-PostHog-Migrated-From" not in request.headers
assert json.loads(request.body) == {
"to": "user@example.com",
"message_data": {"total": 19.5, "expires_at": "2026-11-20T00:00:00+00:00"},
@@ -127,6 +120,33 @@ def test_send_posts_the_template_to_the_app_host(_name, overrides, extra_body):
}
+def test_a_template_send_that_sets_capture_engagement_uses_the_email_send_route():
+ emails, adapter = emails_with([accepted("msg_1")])
+
+ result = send(
+ emails,
+ template_data={"name": "Ada"},
+ distinct_id="user_123",
+ idempotency_key="welcome:user_123",
+ capture_engagement=False,
+ )
+
+ assert result.id == "msg_1"
+ [request] = adapter.requests
+ assert (request.method, request.url) == (
+ "POST",
+ "https://us.posthog.com/api/projects/@current/messaging/send/email/",
+ )
+ assert request.headers["Idempotency-Key"] == "welcome:user_123"
+ assert json.loads(request.body) == {
+ "to": "user@example.com",
+ "template": {"id": TEMPLATE_ID},
+ "message_data": {"name": "Ada"},
+ "identifiers": {"distinct_id": "user_123"},
+ "capture_engagement": False,
+ }
+
+
@parameterized.expand(
[
("default", None, "https://us.posthog.com"),
@@ -158,7 +178,7 @@ def test_a_client_with_a_custom_host_never_sends_it_the_secret_key():
with pytest.raises(EmailError) as raised:
send(client.emails)
- assert raised.value.code == "app_host_required"
+ assert raised.value.code == "missing_app_host"
request.assert_not_called()
@@ -171,18 +191,29 @@ def test_client_email_options_set_the_host_timeout_and_retries():
app_host="https://posthog.example.com/",
email_request_timeout_seconds=3,
email_request_max_retries=0,
+ migrated_from="resend",
)
- unavailable = response(503, {"code": "delivery_unavailable"})
+ adapter = FakeAdapter([rejected(503, "delivery_unavailable")])
with mock.patch.object(
- requests.Session, "request", return_value=unavailable
- ) as request:
+ requests.adapters.HTTPAdapter,
+ "send",
+ autospec=True,
+ side_effect=lambda _transport, request, **_: adapter.send(request),
+ ) as transport:
with pytest.raises(EmailError):
send(client.emails)
- request.assert_called_once()
- assert request.call_args.args[1].startswith("https://posthog.example.com/api/")
- assert request.call_args.kwargs["timeout"] == 3
+ [request] = adapter.requests
+ assert (request.url or "").startswith("https://posthog.example.com/api/")
+ assert transport.call_args.kwargs["timeout"] == 3
+ assert request.headers["X-PostHog-Migrated-From"] == "resend"
+
+
+@parameterized.expand([("empty", ""), ("line_break", "resend\n"), ("not_a_string", 1)])
+def test_rejects_a_migrated_from_that_is_not_a_header_value(_name, migrated_from):
+ with pytest.raises(ValueError):
+ Emails(SECRET_KEY, migrated_from=migrated_from)
@parameterized.expand(
@@ -254,20 +285,16 @@ def test_network_failure_after_retries_is_service_unavailable():
)
-def test_waits_for_retry_after_and_gives_up_on_a_longer_one(no_sleep):
+@pytest.mark.parametrize(("retry_after", "waited"), [("5", 5.0), ("120", 30.0)])
+def test_waits_for_retry_after_up_to_30_seconds(retry_after, waited, no_sleep):
emails, adapter = emails_with(
- [rejected(429, "throttled", **{"Retry-After": "5"}), accepted()]
+ [rejected(429, "throttled", **{"Retry-After": retry_after}), accepted()]
)
- send(emails)
- no_sleep.assert_called_once_with(5.0)
- emails, adapter = emails_with(
- [rejected(429, "throttled", **{"Retry-After": "120"})]
- )
- with pytest.raises(EmailError) as raised:
- send(emails)
- assert raised.value.retry_after == 120.0
- assert len(adapter.requests) == 1
+ assert send(emails).id == "msg_1"
+
+ no_sleep.assert_called_once_with(waited)
+ assert len(adapter.requests) == 2
def test_max_retries_zero_leaves_retries_to_the_caller():
@@ -281,6 +308,31 @@ def test_max_retries_zero_leaves_retries_to_the_caller():
assert len(adapter.requests) == 1
+@parameterized.expand(
+ [
+ ("turns_retries_off", 2, 0, False),
+ ("turns_retries_on", 0, 1, True),
+ ]
+)
+def test_a_send_overrides_the_clients_max_retries(
+ _name, client_max_retries, send_max_retries, retried
+):
+ emails, adapter = emails_with(
+ [rejected(503, "delivery_unavailable"), accepted()],
+ max_retries=client_max_retries,
+ )
+
+ if retried:
+ assert send(emails, max_retries=send_max_retries).id == "msg_1"
+ assert len(adapter.requests) == 2
+ else:
+ with pytest.raises(EmailError) as raised:
+ send(emails, max_retries=send_max_retries)
+ assert raised.value.code == "delivery_unavailable"
+ assert len(adapter.requests) == 1
+ assert "max_retries" not in json.loads(adapter.requests[0].body)
+
+
def test_an_acceptance_without_a_message_id_is_not_retried():
emails, adapter = emails_with([(202, {}, {}), accepted()])
@@ -291,18 +343,17 @@ def test_an_acceptance_without_a_message_id_is_not_retried():
assert len(adapter.requests) == 1
-def test_replays_and_warnings_reach_the_caller(caplog):
+def test_warnings_reach_the_caller(caplog):
warning = {
"code": "template_superseded",
"detail": "Newer content.",
"fix": "Upload.",
}
- emails, _ = emails_with([accepted("msg_1", replayed=True, warnings=[warning])])
+ emails, _ = emails_with([accepted("msg_1", warnings=[warning])])
with caplog.at_level(logging.WARNING, logger="posthog"):
result = send(emails)
- assert result.replayed is True
assert [(w.code, w.fix) for w in result.warnings] == [
("template_superseded", "Upload.")
]
@@ -323,15 +374,19 @@ def test_missing_secret_key_fails_before_sending():
request.assert_not_called()
-def test_a_disabled_client_sends_nothing():
+def test_a_disabled_client_refuses_the_send():
client = Posthog("phc_test", secret_key=SECRET_KEY, sync_mode=True)
emails = client.emails
client.disabled = True
with mock.patch.object(requests.Session, "request") as request:
- result = send(emails)
+ with pytest.raises(EmailError) as raised:
+ send(emails)
- assert result.id is None
+ assert (raised.value.code, raised.value.name) == (
+ "client_disabled",
+ "application_error",
+ )
request.assert_not_called()
@@ -352,23 +407,70 @@ def test_a_shut_down_client_sends_no_email():
@parameterized.expand(
[
- ("several_recipients", {"to": ["a@example.com", "b@example.com"]}, TypeError),
- ("empty_recipient", {"to": " "}, ValueError),
- ("template_name", {"template_id": "welcome"}, ValueError),
- ("template_data_list", {"template_data": [1, 2]}, TypeError),
- ("key_with_space", {"idempotency_key": "welcome user"}, ValueError),
- ("key_too_long", {"idempotency_key": "k" * 256}, ValueError),
+ ("several_recipients", {"to": ["a@example.com"]}, "invalid_form", "to"),
+ ("empty_recipient", {"to": " "}, "invalid_form", "to"),
+ (
+ "template_name",
+ {"template_id": "welcome"},
+ "invalid_template_id",
+ "template_id",
+ ),
+ ("negative_max_retries", {"max_retries": -1}, "invalid_form", "max_retries"),
+ (
+ "capture_engagement_not_bool",
+ {"capture_engagement": "no"},
+ "invalid_form",
+ "capture_engagement",
+ ),
+ ("max_retries_not_int", {"max_retries": True}, "invalid_form", "max_retries"),
+ (
+ "template_data_list",
+ {"template_data": [1, 2]},
+ "invalid_form",
+ "template_data",
+ ),
+ (
+ "key_with_space",
+ {"idempotency_key": "a b"},
+ "invalid_idempotency_key",
+ "idempotency_key",
+ ),
+ (
+ "key_with_trailing_newline",
+ {"idempotency_key": "a\n"},
+ "invalid_idempotency_key",
+ "idempotency_key",
+ ),
+ (
+ "key_too_long",
+ {"idempotency_key": "k" * 256},
+ "invalid_idempotency_key",
+ "idempotency_key",
+ ),
]
)
-def test_rejects_invalid_arguments_before_sending(_name, overrides, error):
+def test_rejects_invalid_arguments_before_sending(_name, overrides, code, field):
emails, adapter = emails_with([])
- with pytest.raises(error):
+ with pytest.raises(EmailValidationError) as raised:
send(emails, **overrides)
+ assert_validation_error(raised.value, code, field)
assert adapter.requests == []
+def assert_validation_error(
+ error: EmailValidationError, code: str, field: Optional[str]
+) -> None:
+ assert isinstance(error, ValueError)
+ assert (error.name, error.code, error.field, error.status_code) == (
+ "validation_error",
+ code,
+ field,
+ None,
+ )
+
+
@pytest.mark.asyncio
async def test_async_send_retries_with_the_same_key():
seen: list[httpx.Request] = []
@@ -400,7 +502,27 @@ def handler(request: httpx.Request) -> httpx.Response:
@pytest.mark.asyncio
-async def test_async_disabled_client_sends_nothing():
+async def test_async_send_overrides_the_clients_max_retries():
+ handler = mock.Mock(
+ return_value=httpx.Response(503, json={"code": "delivery_unavailable"})
+ )
+ emails = AsyncEmails(
+ SECRET_KEY,
+ max_retries=2,
+ http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
+ )
+
+ with mock.patch("posthog.emails.asyncio.sleep"):
+ with pytest.raises(EmailError):
+ await emails.send(
+ to="user@example.com", template_id=TEMPLATE_ID, max_retries=0
+ )
+
+ handler.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_async_disabled_client_refuses_the_send():
handler = mock.Mock(side_effect=AssertionError("a disabled client sent a request"))
emails = AsyncEmails(
SECRET_KEY,
@@ -408,9 +530,10 @@ async def test_async_disabled_client_sends_nothing():
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
- result = await emails.send(to="user@example.com", template_id=TEMPLATE_ID)
+ with pytest.raises(EmailError) as raised:
+ await emails.send(to="user@example.com", template_id=TEMPLATE_ID)
- assert result.id is None
+ assert raised.value.code == "client_disabled"
handler.assert_not_called()
@@ -449,3 +572,482 @@ async def handler(request: httpx.Request) -> httpx.Response:
with pytest.raises(EmailError) as raised:
await emails.send(to="user@example.com", template_id=TEMPLATE_ID)
assert raised.value.code == "client_closed"
+
+
+CODE_SEND_URL = "https://us.posthog.com/api/projects/@current/messaging/send/email/"
+HTML = "Reset it here.
"
+
+
+def send_code(emails: Emails, **overrides: Any):
+ return emails.send(
+ **{
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ **overrides,
+ }
+ )
+
+
+@parameterized.expand(
+ [
+ ("html_only", {"html": HTML}, {"html": HTML}),
+ ("text_only", {"text": "Reset it here."}, {"text": "Reset it here."}),
+ (
+ "every_field",
+ {
+ "html": HTML,
+ "text": "Reset it here.",
+ "from_": "Acme ",
+ "reply_to": "help@example.com",
+ "capture_engagement": False,
+ "distinct_id": "user_123",
+ },
+ {
+ "html": HTML,
+ "text": "Reset it here.",
+ "from": "Acme ",
+ "reply_to": ["help@example.com"],
+ "capture_engagement": False,
+ "identifiers": {"distinct_id": "user_123"},
+ },
+ ),
+ (
+ "several_reply_to",
+ {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]},
+ {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]},
+ ),
+ (
+ "empty_address_lists",
+ {"html": HTML, "reply_to": [], "cc": [], "bcc": []},
+ {"html": HTML, "reply_to": [], "cc": [], "bcc": []},
+ ),
+ ]
+)
+def test_rendered_content_is_posted_as_is(_name, overrides, extra_body):
+ emails, adapter = emails_with([accepted("msg_1")])
+
+ result = send_code(emails, idempotency_key="reset:user_123", **overrides)
+
+ assert result.id == "msg_1"
+ [request] = adapter.requests
+ assert (request.method, request.url) == ("POST", CODE_SEND_URL)
+ assert request.headers["Idempotency-Key"] == "reset:user_123"
+ assert json.loads(request.body) == {
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ **extra_body,
+ }
+
+
+WELCOME = {
+ "template_key": "welcome",
+ "subject": "Welcome, {{ variables.first_name }}",
+ "html": "Hi {{ variables.first_name }}
",
+}
+# The server's content_hash v1 test vectors, shared with posthog-node.
+WELCOME_HASH = "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6"
+WELCOME_CRLF_TEXT_HASH = (
+ "v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453"
+)
+UPLOAD_URL = (
+ "https://us.posthog.com/api/projects/@current/messaging_templates/by_key/welcome/"
+)
+
+
+def send_local_template(emails: Emails, **overrides: Any):
+ return emails.send(
+ **{
+ "to": "user@example.com",
+ **WELCOME,
+ "template_data": {"first_name": "Jane"},
+ **overrides,
+ }
+ )
+
+
+@parameterized.expand(
+ [
+ ("html_only", {}, WELCOME_HASH),
+ ("crlf_text", {"text": "\r\n\r"}, WELCOME_CRLF_TEXT_HASH),
+ ("empty_text", {"text": ""}, WELCOME_HASH),
+ ]
+)
+def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash):
+ emails, adapter = emails_with([accepted("msg_1")])
+
+ result = send_local_template(
+ emails, from_="Acme ", distinct_id="user_123", **overrides
+ )
+
+ assert result.id == "msg_1"
+ [request] = adapter.requests
+ assert (request.method, request.url) == ("POST", CODE_SEND_URL)
+ assert json.loads(request.body) == {
+ "to": "user@example.com",
+ "template": {"key": "welcome", "hash": hash},
+ "message_data": {"first_name": "Jane"},
+ "from": "Acme ",
+ "identifiers": {"distinct_id": "user_123"},
+ }
+
+
+def uploaded() -> Reply:
+ return (201, {"id": "tpl_1", "key": "welcome", "result": "created"}, {})
+
+
+def test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_key():
+ emails, adapter = emails_with(
+ [rejected(409, "template_outdated"), uploaded(), accepted("msg_1")]
+ )
+
+ result = send_local_template(emails, text="Hi {{ variables.first_name }}")
+
+ assert result.id == "msg_1"
+ first_send, upload, second_send = adapter.requests
+ assert (upload.method, upload.url) == ("PUT", UPLOAD_URL)
+ assert json.loads(upload.body) == {
+ "name": "welcome",
+ "subject": "Welcome, {{ variables.first_name }}",
+ "html": "Hi {{ variables.first_name }}
",
+ "text": "Hi {{ variables.first_name }}",
+ "if_not_superseded": True,
+ "if_code_managed": True,
+ }
+ assert "Idempotency-Key" not in upload.headers
+ assert upload.headers["Authorization"] == f"Bearer {SECRET_KEY}"
+ assert second_send.body == first_send.body
+ assert (
+ second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"]
+ )
+
+
+def test_a_sends_max_retries_also_covers_its_upload():
+ emails, adapter = emails_with(
+ [
+ rejected(409, "template_outdated"),
+ rejected(503, "delivery_unavailable"),
+ uploaded(),
+ accepted(),
+ ],
+ max_retries=0,
+ )
+
+ assert send_local_template(emails, max_retries=1).id == "msg_1"
+ assert [request.method for request in adapter.requests] == [
+ "POST",
+ "PUT",
+ "PUT",
+ "POST",
+ ]
+
+
+def test_every_request_of_a_send_says_where_the_caller_migrated_from():
+ emails, adapter = emails_with(
+ [rejected(409, "template_outdated"), uploaded(), accepted()],
+ migrated_from="resend",
+ )
+
+ send_local_template(emails)
+
+ assert [
+ request.headers["X-PostHog-Migrated-From"] for request in adapter.requests
+ ] == [
+ "resend",
+ "resend",
+ "resend",
+ ]
+
+
+@parameterized.expand(
+ [
+ ("refused_upload", rejected(409, "template_edited_in_posthog"), 2),
+ ("outdated_again", None, 3),
+ ]
+)
+def test_a_local_template_uploads_at_most_once_per_send(
+ _name, upload_reply, requests_made
+):
+ replies = (
+ [rejected(409, "template_outdated"), upload_reply]
+ if upload_reply
+ else [
+ rejected(409, "template_outdated"),
+ uploaded(),
+ rejected(409, "template_outdated"),
+ ]
+ )
+ emails, adapter = emails_with(replies)
+
+ with pytest.raises(EmailError) as raised:
+ send_local_template(emails)
+
+ assert (raised.value.status_code, raised.value.name) == (409, "validation_error")
+ assert len(adapter.requests) == requests_made
+
+
+def test_rendered_content_is_never_uploaded():
+ emails, adapter = emails_with([rejected(409, "template_outdated")])
+
+ with pytest.raises(EmailError):
+ send_code(emails, html=HTML)
+
+ assert [request.method for request in adapter.requests] == ["POST"]
+
+
+@pytest.mark.asyncio
+async def test_async_uploads_an_outdated_local_template_then_sends_again():
+ seen: list[httpx.Request] = []
+ replies = [
+ httpx.Response(409, json={"code": "template_outdated"}),
+ httpx.Response(201, json={"id": "tpl_1", "result": "created"}),
+ httpx.Response(202, json={"message_id": "msg_1"}),
+ ]
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ return replies.pop(0)
+
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
+ result = await AsyncEmails(
+ SECRET_KEY, http_client=client, migrated_from="resend"
+ ).send(to="user@example.com", **WELCOME, template_data={"first_name": "Jane"})
+
+ assert result.id == "msg_1"
+ assert [(request.method, str(request.url)) for request in seen] == [
+ ("POST", CODE_SEND_URL),
+ ("PUT", UPLOAD_URL),
+ ("POST", CODE_SEND_URL),
+ ]
+ first_send, upload, second_send = seen
+ assert {request.headers["X-PostHog-Migrated-From"] for request in seen} == {
+ "resend"
+ }
+ assert json.loads(first_send.content)["template"] == {
+ "key": "welcome",
+ "hash": WELCOME_HASH,
+ }
+ assert "Idempotency-Key" not in upload.headers
+ assert second_send.content == first_send.content
+ assert (
+ second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"]
+ )
+
+
+@parameterized.expand(
+ [
+ (
+ "both_forms",
+ {"template_id": TEMPLATE_ID, "html": HTML},
+ "invalid_form",
+ None,
+ ),
+ ("no_form", {"template_key": None}, "invalid_form", None),
+ ("no_content", {}, "invalid_form", "html"),
+ ("no_subject", {"subject": None, "html": HTML}, "invalid_form", "subject"),
+ ("subject_not_text", {"subject": 1, "html": HTML}, "invalid_form", "subject"),
+ ("from_not_text", {"html": HTML, "from_": 1}, "invalid_form", "from_"),
+ (
+ "from_keyword",
+ {"html": HTML, "from": "a@example.com"},
+ "invalid_form",
+ "from",
+ ),
+ (
+ "key_with_space",
+ {"template_key": "a b", "html": HTML},
+ "invalid_template_key",
+ "template_key",
+ ),
+ (
+ "key_with_trailing_newline",
+ {"template_key": "a\n", "html": HTML},
+ "invalid_template_key",
+ "template_key",
+ ),
+ (
+ "key_too_long",
+ {"template_key": "k" * 401, "html": HTML},
+ "invalid_template_key",
+ "template_key",
+ ),
+ ("empty_html", {"html": ""}, "invalid_form", "html"),
+ ("empty_html_and_text", {"html": "", "text": ""}, "invalid_form", "html"),
+ ("blank_cc", {"html": HTML, "cc": " "}, "invalid_form", "cc"),
+ ("cc_not_text", {"html": HTML, "cc": 123}, "invalid_form", "cc"),
+ (
+ "template_data_list",
+ {"html": HTML, "template_data": [1]},
+ "invalid_form",
+ "template_data",
+ ),
+ (
+ "cc_on_template_form",
+ {
+ "template_id": TEMPLATE_ID,
+ "template_key": None,
+ "subject": None,
+ "cc": "a@example.com",
+ },
+ "invalid_form",
+ "cc",
+ ),
+ ]
+)
+def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, code, field):
+ emails, adapter = emails_with([])
+ args = {
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ **overrides,
+ }
+
+ with pytest.raises(EmailValidationError) as raised:
+ emails.send(
+ **{name: value for name, value in args.items() if value is not None}
+ )
+
+ assert_validation_error(raised.value, code, field)
+ assert adapter.requests == []
+
+
+COPIES_HEADERS_AND_TAGS = {
+ "cc": "manager@example.com",
+ "bcc": ["audit@example.com", "archive@example.com"],
+ "headers": {"X-Entity-Ref-ID": "order-123"},
+ "tags": [{"name": "category", "value": "password_reset"}],
+}
+COPIES_HEADERS_AND_TAGS_BODY = {
+ "cc": ["manager@example.com"],
+ "bcc": ["audit@example.com", "archive@example.com"],
+ "headers": {"X-Entity-Ref-ID": "order-123"},
+ "tags": [{"name": "category", "value": "password_reset"}],
+}
+
+
+def test_rendered_content_carries_copies_headers_and_tags():
+ emails, adapter = emails_with([accepted()])
+
+ send_code(emails, html=HTML, **COPIES_HEADERS_AND_TAGS)
+
+ [request] = adapter.requests
+ assert json.loads(request.body) == {
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ "html": HTML,
+ **COPIES_HEADERS_AND_TAGS_BODY,
+ }
+
+
+def test_a_server_that_does_not_take_a_field_refuses_the_send_and_names_it():
+ refused = {
+ "type": "invalid_request",
+ "code": "invalid_request",
+ "detail": "cc: PostHog doesn't take this field on this route.",
+ "attr": "cc",
+ }
+ emails, adapter = emails_with([(400, refused, {}), accepted()])
+
+ with pytest.raises(EmailError) as raised:
+ send_code(emails, html=HTML, cc="manager@example.com")
+
+ error = raised.value
+ assert (error.name, error.code, error.field) == (
+ "validation_error",
+ "invalid_request",
+ "cc",
+ )
+ assert len(adapter.requests) == 1
+
+
+def test_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them():
+ emails, adapter = emails_with(
+ [rejected(409, "template_outdated"), uploaded(), accepted()]
+ )
+
+ send_local_template(emails, **COPIES_HEADERS_AND_TAGS)
+
+ first_send, upload, _ = adapter.requests
+ sent = json.loads(first_send.body)
+ assert {field: sent[field] for field in COPIES_HEADERS_AND_TAGS_BODY} == (
+ COPIES_HEADERS_AND_TAGS_BODY
+ )
+ assert not set(COPIES_HEADERS_AND_TAGS_BODY) & set(json.loads(upload.body))
+
+
+INVITE = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
+
+
+def test_attachments_are_sent_base64_encoded_once_across_retries():
+ emails, adapter = emails_with([rejected(503, "delivery_unavailable"), accepted()])
+
+ send_code(
+ emails,
+ html=HTML,
+ attachments=[
+ {
+ "filename": "invite.ics",
+ "content": INVITE,
+ "content_type": "text/calendar; method=REQUEST",
+ },
+ {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"},
+ ],
+ )
+
+ first, retry = adapter.requests
+ assert json.loads(first.body)["attachments"] == [
+ {
+ "filename": "invite.ics",
+ "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=",
+ "content_type": "text/calendar; method=REQUEST",
+ },
+ {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"},
+ ]
+ assert retry.body == first.body
+
+
+@parameterized.expand(
+ [
+ ("bytearray", bytearray(INVITE)),
+ ("memoryview", memoryview(INVITE)),
+ (
+ "strided_memoryview",
+ memoryview(bytes(b for byte in INVITE for b in (byte, 0)))[::2],
+ ),
+ ]
+)
+def test_attachment_content_from_any_bytes_buffer_is_base64_encoded(_name, content):
+ emails, adapter = emails_with([accepted()])
+
+ send_code(
+ emails, html=HTML, attachments=[{"filename": "invite.ics", "content": content}]
+ )
+
+ [request] = adapter.requests
+ assert json.loads(request.body)["attachments"] == [
+ {
+ "filename": "invite.ics",
+ "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=",
+ }
+ ]
+
+
+@parameterized.expand(
+ [
+ ("list_of_ints", {"filename": "a.txt", "content": [104, 105]}),
+ ("no_filename", {"content": b"104"}),
+ ("unknown_field", {"filename": "a.txt", "content": b"104", "path": "/tmp/a"}),
+ ]
+)
+def test_rejects_an_attachment_that_is_not_a_file(_name, attachment):
+ emails, adapter = emails_with([])
+
+ with pytest.raises(EmailValidationError) as raised:
+ send_code(emails, html=HTML, attachments=[attachment])
+
+ assert_validation_error(raised.value, "invalid_form", "attachments")
+ assert "104" not in str(raised.value)
+ assert adapter.requests == []
diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt
index 7bd9a74ce..421b49ece 100644
--- a/references/public_api_snapshot.txt
+++ b/references/public_api_snapshot.txt
@@ -692,6 +692,7 @@ attribute posthog.async_client.AsyncClient.is_server = is_server
attribute posthog.async_client.AsyncClient.log = logging.getLogger('posthog')
attribute posthog.async_client.AsyncClient.log_captured_exceptions = log_captured_exceptions
attribute posthog.async_client.AsyncClient.max_retries = max(0, max_retries)
+attribute posthog.async_client.AsyncClient.migrated_from = migrated_from
attribute posthog.async_client.AsyncClient.on_error = on_error
attribute posthog.async_client.AsyncClient.personal_api_key = self.secret_key
attribute posthog.async_client.AsyncClient.project_root = os.getcwd()
@@ -762,6 +763,7 @@ attribute posthog.client.Client.log = logging.getLogger('posthog')
attribute posthog.client.Client.log_captured_exceptions = log_captured_exceptions
attribute posthog.client.Client.max_retries = max(0, max_retries)
attribute posthog.client.Client.metrics: PostHogMetrics
+attribute posthog.client.Client.migrated_from = migrated_from
attribute posthog.client.Client.on_error = on_error
attribute posthog.client.Client.personal_api_key = self.secret_key
attribute posthog.client.Client.poll_interval = poll_interval
@@ -818,6 +820,23 @@ attribute posthog.debug = False
attribute posthog.default_client = None
attribute posthog.disable_geoip = True
attribute posthog.disabled = False
+attribute posthog.emails.CodeEmailArgs.attachments: NotRequired[Sequence[EmailAttachment]]
+attribute posthog.emails.CodeEmailArgs.bcc: NotRequired[Union[str, Sequence[str]]]
+attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[bool]
+attribute posthog.emails.CodeEmailArgs.cc: NotRequired[Union[str, Sequence[str]]]
+attribute posthog.emails.CodeEmailArgs.from_: NotRequired[str]
+attribute posthog.emails.CodeEmailArgs.headers: NotRequired[Mapping[str, str]]
+attribute posthog.emails.CodeEmailArgs.html: NotRequired[str]
+attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Union[str, Sequence[str]]]
+attribute posthog.emails.CodeEmailArgs.subject: str
+attribute posthog.emails.CodeEmailArgs.tags: NotRequired[Sequence[EmailTag]]
+attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Mapping[str, Any]]
+attribute posthog.emails.CodeEmailArgs.template_key: str
+attribute posthog.emails.CodeEmailArgs.text: NotRequired[str]
+attribute posthog.emails.EmailAttachment.content: Union[bytes, bytearray, memoryview, str]
+attribute posthog.emails.EmailAttachment.content_id: NotRequired[str]
+attribute posthog.emails.EmailAttachment.content_type: NotRequired[str]
+attribute posthog.emails.EmailAttachment.filename: str
attribute posthog.emails.EmailError.code = code
attribute posthog.emails.EmailError.docs_url = docs_url
attribute posthog.emails.EmailError.field = field
@@ -827,12 +846,16 @@ attribute posthog.emails.EmailError.name = name
attribute posthog.emails.EmailError.retry_after = retry_after
attribute posthog.emails.EmailError.status_code = status_code
attribute posthog.emails.EmailError.why = why
+attribute posthog.emails.EmailTag.name: str
+attribute posthog.emails.EmailTag.value: str
attribute posthog.emails.EmailWarning.code: str
attribute posthog.emails.EmailWarning.fix: Optional[str] = None
attribute posthog.emails.EmailWarning.message: str
-attribute posthog.emails.SendEmailResult.id: Optional[str]
-attribute posthog.emails.SendEmailResult.replayed: bool = False
+attribute posthog.emails.SendEmailResult.id: str
attribute posthog.emails.SendEmailResult.warnings: tuple[EmailWarning, ...] = ()
+attribute posthog.emails.TemplateEmailArgs.capture_engagement: NotRequired[bool]
+attribute posthog.emails.TemplateEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]]
+attribute posthog.emails.TemplateEmailArgs.template_id: Union[str, UUID]
attribute posthog.enable_exception_autocapture = False
attribute posthog.enable_exception_autocapture_rate_limiting = False
attribute posthog.enable_full_ai_capture = False
@@ -1229,20 +1252,25 @@ class posthog.ai.types.TokenUsage
class posthog.ai.types.ToolInProgress
class posthog.args.OptionalCaptureArgs
class posthog.args.OptionalSetArgs
-class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2)
+class posthog.async_client.AsyncClient(project_api_key: str, host: Optional[str] = None, debug: bool = False, max_queue_size: int = 10000, send: bool = True, on_error=None, flush_at: int = 100, flush_interval: float = 5.0, gzip: bool = False, max_retries: int = 3, timeout: int = 15, thread: int = 1, disabled: bool = False, disable_geoip: bool = True, is_server: bool = True, historical_migration: bool = False, super_properties: Optional[dict[str, Any]] = None, before_send=None, log_captured_exceptions: bool = False, project_root: Optional[str] = None, capture_exception_code_variables: bool = False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: Optional[list[str]] = None, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, capture_trace_context: bool = False, secret_key: Optional[str] = None, personal_api_key: Optional[str] = None, feature_flags_request_timeout_seconds: int = 3, feature_flags_request_max_retries: int = 1, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, migrated_from: Optional[str] = None)
class posthog.async_client.AsyncPosthog
class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic)
class posthog.capture_compression.CaptureCompression
class posthog.capture_mode.CaptureMode
class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None)
-class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2)
+class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, app_host: Optional[str] = None, email_request_timeout_seconds: float = 15, email_request_max_retries: int = 2, migrated_from: Optional[str] = None)
class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE)
class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None)
-class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None)
+class posthog.emails.AsyncEmails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, http_client: Any = None, migrated_from: Optional[str] = None)
+class posthog.emails.CodeEmailArgs
+class posthog.emails.EmailAttachment
class posthog.emails.EmailError(*, name: str, code: str, message: str, status_code: Optional[int] = None, why: Optional[str] = None, fix: Optional[str] = None, docs_url: Optional[str] = None, field: Optional[str] = None, retry_after: Optional[float] = None)
+class posthog.emails.EmailTag
+class posthog.emails.EmailValidationError(*, code: str, message: str, field: Optional[str])
class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None)
-class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None)
-class posthog.emails.SendEmailResult(id: Optional[str], replayed: bool = False, warnings: tuple[EmailWarning, ...] = ())
+class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None, migrated_from: Optional[str] = None)
+class posthog.emails.SendEmailResult(id: str, warnings: tuple[EmailWarning, ...] = ())
+class posthog.emails.TemplateEmailArgs
class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS)
class posthog.exception_utils.AnnotatedValue(value, metadata)
class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT)
@@ -1704,9 +1732,9 @@ method posthog.contexts.ContextScope.set_device_id(device_id: str)
method posthog.contexts.ContextScope.set_distinct_id(distinct_id: str)
method posthog.contexts.ContextScope.set_session_id(session_id: str)
method posthog.emails.AsyncEmails.aclose() -> None
-method posthog.emails.AsyncEmails.send(*, to: str, template_id: Union[str, UUID], template_data: Optional[Mapping[str, Any]] = None, distinct_id: Optional[str] = None, idempotency_key: Optional[str] = None) -> SendEmailResult
+method posthog.emails.AsyncEmails.send(**kwargs: Any) -> SendEmailResult
method posthog.emails.Emails.close() -> None
-method posthog.emails.Emails.send(*, to: str, template_id: Union[str, UUID], template_data: Optional[Mapping[str, Any]] = None, distinct_id: Optional[str] = None, idempotency_key: Optional[str] = None) -> SendEmailResult
+method posthog.emails.Emails.send(**kwargs: Any) -> SendEmailResult
method posthog.exception_capture.ExceptionCapture.capture_exception(exception, metadata=None)
method posthog.exception_capture.ExceptionCapture.close()
method posthog.exception_capture.ExceptionCapture.exception_handler(exc_type, exc_value, exc_traceback)