diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 170f9ec6d..7703ab786 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -41,8 +41,8 @@ span.end() _ = (flag_value, all_flags, enabled, payload, evaluations, active) -emails = Emails("phs_example") -template_send: SendEmailResult = emails.send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}) +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_="AcmeInvite
", 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"}]) diff --git a/.sampo/changesets/emails-code-form.md b/.sampo/changesets/emails-code-form.md index 7a2051647..f9b8d0ac4 100644 --- a/.sampo/changesets/emails-code-form.md +++ b/.sampo/changesets/emails-code-form.md @@ -2,6 +2,6 @@ pypi/posthog: minor --- -Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`. +Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. 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..ba88b9d57 --- /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`. Every send says whether to capture opens and clicks: `capture_engagement` defaults to `True` on both forms, and `False` turns capture off for that send. 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 7a1f62269..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 ) @@ -266,7 +270,8 @@ def emails(self) -> AsyncEmails: 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 1eb1207ac..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 @@ -1157,8 +1163,9 @@ def emails(self) -> Emails: 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 69ff9f79e..78742fa0a 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -57,31 +57,43 @@ 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=False`` 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 @@ -97,7 +109,7 @@ import re import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import TracebackType from typing import ( Any, @@ -131,6 +143,7 @@ "EmailAttachment", "EmailError", "EmailTag", + "EmailValidationError", "EmailWarning", "Emails", "SendEmailResult", @@ -145,7 +158,8 @@ _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"}) @@ -183,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 @@ -192,10 +224,9 @@ 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, ...] = () @@ -228,6 +259,7 @@ class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] idempotency_key: NotRequired[Optional[str]] + max_retries: NotRequired[int] class TemplateEmailArgs(_RecipientArgs): @@ -240,10 +272,18 @@ class TemplateEmailArgs(_RecipientArgs): 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 the email's opens and clicks. It + defaults to ``True``. ``False`` turns capture off for this send. + A project whose admin turned engagement capture off captures + nothing, whatever the send asks. + ``False`` 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): @@ -275,8 +315,10 @@ class CodeEmailArgs(_RecipientArgs): 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. + capture_engagement: Capture the email's opens and clicks. It + defaults to ``True``. ``False`` turns capture off for this send. + A project whose admin turned engagement capture off captures + nothing, whatever the send asks. 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 @@ -284,6 +326,7 @@ class CodeEmailArgs(_RecipientArgs): 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 @@ -313,58 +356,63 @@ class _PreparedSend: 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 TypeError("Pass either template_id or template_key.") + 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 TypeError( + raise _invalid( + unexpected[0], f"send() got arguments that the {'template' if is_template_form else 'code'} " - f"form doesn't take: {', '.join(unexpected)}." + f"form doesn't take: {', '.join(unexpected)}.", ) if "to" not in args: - raise TypeError("send() is missing the argument to.") - if is_template_form: - return _prepare_send( - args["to"], - args["template_id"], - args.get("template_data"), - args.get("distinct_id"), - args.get("idempotency_key"), - ) - return _prepare_code_send(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 _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 TypeError("send() is missing the argument subject.") + 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 ValueError( - "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400." + 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 TypeError("send() needs html, text or both.") + raise _invalid("html", "send() needs html, text or both.") if not (content.get("html") or content.get("text")): - raise ValueError("html or text must be a nonempty string.") + 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 TypeError("template_data must be a 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( @@ -414,21 +462,27 @@ def _sender(args: Mapping[str, Any]) -> dict[str, Any]: sender["tags"] = _tags(args["tags"]) if "attachments" in args: sender["attachments"] = _attachments(args["attachments"]) - if "capture_engagement" in args: - if not isinstance(args["capture_engagement"], bool): - raise TypeError("capture_engagement must be True or False.") - sender["capture_engagement"] = args["capture_engagement"] + sender["capture_engagement"] = _capture_engagement(args) return sender +def _capture_engagement(args: Mapping[str, Any]) -> bool: + capture = args.get("capture_engagement", True) + if not isinstance(capture, bool): + raise _invalid( + "capture_engagement", "capture_engagement must be True or False." + ) + return capture + + def _addresses(field: str, value: Any) -> list[str]: addresses = [value] if isinstance(value, str) else value if not isinstance(addresses, Sequence) or not all( isinstance(address, str) for address in addresses ): - raise TypeError(f"{field} must be one address or a list of addresses.") + raise _invalid(field, f"{field} must be one address or a list of addresses.") if not all(address.strip() for address in addresses): - raise ValueError(f"{field} must not hold a blank address.") + raise _invalid(field, f"{field} must not hold a blank address.") return list(addresses) @@ -437,7 +491,7 @@ def _headers_field(headers: Any) -> dict[str, str]: isinstance(name, str) and isinstance(value, str) for name, value in headers.items() ): - raise TypeError("headers must map header names to string values.") + raise _invalid("headers", "headers must map header names to string values.") return dict(headers) @@ -447,28 +501,30 @@ def _tags(tags: Any) -> list[dict[str, str]]: or not isinstance(tags, Sequence) or not all(_is_tag(tag) for tag in tags) ): - raise TypeError("tags must be a list of {'name': str, 'value': str}.") + 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 TypeError("attachments must be a list of files.") + 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 TypeError(f"{name} needs a filename and its content.") + raise _invalid("attachments", f"{name} needs a filename and its content.") unexpected = set(file) - set(EmailAttachment.__annotations__) if unexpected: - raise TypeError( - f"{name} has fields an attachment doesn't take: {', '.join(sorted(unexpected))}." + 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 TypeError( - f"{name} needs its filename, content_type and content_id as strings." + raise _invalid( + "attachments", + f"{name} needs its filename, content_type and content_id as strings.", ) return {**file, "content": _base64_content(name, file["content"])} @@ -478,9 +534,10 @@ def _base64_content(name: str, content: Any) -> str: return base64.b64encode(bytes(content)).decode("ascii") if isinstance(content, str): return content - raise TypeError( + raise _invalid( + "attachments", f"{name}.content must be bytes or a Base64 string. " - "Convert a list of byte values with bytes(content)." + "Convert a list of byte values with bytes(content).", ) @@ -496,14 +553,14 @@ def _is_tag(tag: Any) -> bool: def _string(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str): - raise TypeError(f"{name} must be a string.") + 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 ValueError(f"{name} must not be empty.") + raise _invalid(name, f"{name} must not be empty.") return value @@ -513,22 +570,24 @@ def _encode(payload: Mapping[str, Any]) -> bytes: ).encode("utf-8") -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: - to, distinct_id, idempotency_key = _recipient(to, distinct_id, idempotency_key) +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.") + raise _invalid("template_data", "template_data must be a mapping.") payload: dict[str, Any] = { "to": to, @@ -536,6 +595,14 @@ def _prepare_send( } if distinct_id is not None: payload["identifiers"] = {"distinct_id": distinct_id} + payload["capture_engagement"] = _capture_engagement(args) + if not payload["capture_engagement"]: + # The template route drops fields it doesn't know, so only the email send route can turn capture off. + return _PreparedSend( + path=_CODE_SEND_PATH, + body=_encode({"template": {"id": str(template_uuid)}, **payload}), + idempotency_key=idempotency_key, + ) return _PreparedSend( path=_SEND_PATH.format(template_id=template_uuid), body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"), @@ -547,25 +614,29 @@ def _recipient( to: Any, distinct_id: Any, idempotency_key: Any ) -> tuple[str, Optional[str], str]: if not isinstance(to, str): - raise TypeError("to must be one email address as a string.") + raise _invalid("to", "to must be one email address as a string.") if not to.strip(): - raise ValueError("to must not be empty.") + 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 ValueError("distinct_id must be a nonempty string.") + 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.match( + elif not isinstance(idempotency_key, str) or not _IDEMPOTENCY_KEY.fullmatch( idempotency_key ): - raise ValueError( - "idempotency_key must be 1 to 255 visible ASCII characters without spaces." + 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) -> None: +def _validate_options( + timeout: float, max_retries: int, migrated_from: Optional[str] +) -> None: if ( isinstance(timeout, bool) or not isinstance(timeout, (int, float)) @@ -573,12 +644,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]: @@ -639,7 +712,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", @@ -692,11 +764,8 @@ 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: @@ -712,7 +781,9 @@ def _log_warnings(result: SendEmailResult) -> None: log.warning("PostHog email warning %s: %s", warning.code, warning.message) -def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: +def _headers( + secret_key: str, idempotency_key: Optional[str], migrated_from: Optional[str] +) -> dict[str, str]: headers = { "Authorization": f"Bearer {secret_key}", "Accept": "application/json", @@ -721,6 +792,8 @@ def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: } 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 @@ -729,8 +802,13 @@ def _headers(secret_key: str, idempotency_key: Optional[str]) -> dict[str, str]: class _BearerAuth(requests.auth.AuthBase): - def __init__(self, secret_key: str, idempotency_key: Optional[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. @@ -747,13 +825,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 @@ -762,6 +842,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( @@ -770,9 +855,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", @@ -792,7 +882,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__( @@ -804,6 +895,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, @@ -811,6 +903,7 @@ 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 @@ -841,10 +934,17 @@ def send(self, **kwargs: Any) -> SendEmailResult: 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 the email's opens and clicks. It + defaults to ``True``. ``False`` turns capture off for this + send. A project whose admin turned engagement capture off + captures nothing, whatever the send asks. Returns: The queued email. A result means PostHog queued the email, not that @@ -853,7 +953,8 @@ def send(self, **kwargs: Any) -> SendEmailResult: 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 @@ -874,9 +975,6 @@ def send(self, **kwargs: Any) -> SendEmailResult: ``` """ prepared = _prepare(kwargs) - if self._is_disabled(): - log.debug("PostHog client is disabled, so no email was sent.") - return SendEmailResult(id=None) finish = self._start_send() try: return self._deliver(prepared) @@ -885,10 +983,11 @@ def send(self, **kwargs: Any) -> SendEmailResult: def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: secret_key = self._secret_key() - auth = _BearerAuth(secret_key, prepared.idempotency_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, _parse_response + "POST", prepared.path, prepared.body, auth, retries, _parse_response ) except EmailError as error: if prepared.upload is None or not _is_outdated(error): @@ -897,11 +996,12 @@ def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: "PUT", prepared.upload.path, prepared.upload.body, - _BearerAuth(secret_key, None), + _BearerAuth(secret_key, None, self.migrated_from), + retries, _parse_upload, ) result = self._request( - "POST", prepared.path, prepared.body, auth, _parse_response + "POST", prepared.path, prepared.body, auth, retries, _parse_response ) _log_warnings(result) return result @@ -912,11 +1012,12 @@ def _request( path: str, body: bytes, auth: _BearerAuth, + max_retries: int, parse: _Parse[_ResultT], ) -> _ResultT: session = self._get_session() url = self.host + path - for attempt in range(self.max_retries + 1): + for attempt in range(max_retries + 1): try: response = session.request( method, @@ -942,7 +1043,7 @@ def _request( 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) @@ -992,6 +1093,7 @@ def __init__( max_retries: int = 2, disabled: bool = False, http_client: Any = None, + migrated_from: Optional[str] = None, ) -> None: super().__init__( secret_key, @@ -999,6 +1101,7 @@ 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 @@ -1026,9 +1129,6 @@ async def send(self, **kwargs: Any) -> SendEmailResult: ``` """ prepared = _prepare(kwargs) - if self._is_disabled(): - log.debug("PostHog client is disabled, so no email was sent.") - return SendEmailResult(id=None) finish = self._start_send() try: return await self._deliver(prepared) @@ -1037,10 +1137,11 @@ async def send(self, **kwargs: Any) -> SendEmailResult: async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: secret_key = self._secret_key() - headers = _headers(secret_key, prepared.idempotency_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, _parse_response + "POST", prepared.path, prepared.body, headers, retries, _parse_response ) except EmailError as error: if prepared.upload is None or not _is_outdated(error): @@ -1049,11 +1150,12 @@ async def _deliver(self, prepared: _PreparedSend) -> SendEmailResult: "PUT", prepared.upload.path, prepared.upload.body, - _headers(secret_key, None), + _headers(secret_key, None, self.migrated_from), + retries, _parse_upload, ) result = await self._request( - "POST", prepared.path, prepared.body, headers, _parse_response + "POST", prepared.path, prepared.body, headers, retries, _parse_response ) _log_warnings(result) return result @@ -1064,11 +1166,12 @@ async def _request( path: str, body: bytes, headers: Mapping[str, str], + max_retries: int, parse: _Parse[_ResultT], ) -> _ResultT: httpx, client = self._get_client() url = self.host + path - for attempt in range(self.max_retries + 1): + for attempt in range(max_retries + 1): try: response = await client.request( method, url, content=body, headers=headers @@ -1089,7 +1192,7 @@ async def _request( 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) @@ -1139,10 +1242,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 @@ -1154,6 +1262,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 b7bb8167c..909142c47 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,19 +107,47 @@ 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"}, + "capture_engagement": True, **extra_body, } +def test_a_template_send_that_turns_capture_off_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 +179,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 +192,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 +286,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 +309,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 +344,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 +375,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 +408,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 +503,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 +531,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() @@ -466,6 +590,39 @@ def send_code(emails: Emails, **overrides: Any): ) +@parameterized.expand( + [ + ("template_form_by_default", send, {}, SEND_URL, True), + ("template_form_on", send, {"capture_engagement": True}, SEND_URL, True), + ( + "template_form_off", + send, + {"capture_engagement": False}, + CODE_SEND_URL, + False, + ), + ("code_form_by_default", send_code, {"html": HTML}, CODE_SEND_URL, True), + ( + "code_form_off", + send_code, + {"html": HTML, "capture_engagement": False}, + CODE_SEND_URL, + False, + ), + ] +) +def test_every_send_says_whether_to_capture_engagement( + _name, send_form, overrides, url, captured +): + emails, adapter = emails_with([accepted()]) + + send_form(emails, **overrides) + + [request] = adapter.requests + assert request.url == url + assert json.loads(request.body)["capture_engagement"] is captured + + @parameterized.expand( [ ("html_only", {"html": HTML}, {"html": HTML}), @@ -514,6 +671,7 @@ def test_rendered_content_is_posted_as_is(_name, overrides, extra_body): "to": "user@example.com", "template_key": "password-reset", "subject": "Reset your password", + "capture_engagement": True, **extra_body, } @@ -567,6 +725,7 @@ def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash "message_data": {"first_name": "Jane"}, "from": "Acme