diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh
index 7b46c0af0..4e41c9e8f 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,16 @@ 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"})
+rendered_send: SendEmailResult = emails.send(to="user@example.com", template_key="password-reset", subject="Reset", html="
Reset
", from_="Acme ", reply_to=["help@example.com"], capture_engagement=False, max_retries=0)
+_ = (template_send, rendered_send)
+
+
+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 +75,26 @@ 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_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", capture_engagement=False) # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="Hi
", template_data={"name": "Ada"}) # expected-type-error
+Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="Hi
", cc="team@example.com") # expected-type-error
+
+
+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..4264b2b9c
--- /dev/null
+++ b/.sampo/changesets/emails-code-form.md
@@ -0,0 +1,28 @@
+---
+pypi/posthog: minor
+---
+
+`posthog.emails.send()` can now send email content your code rendered, such as HTML from Jinja or React Email. Give the email a `template_key` and a `subject`, and pass `html`, `text` or both.
+
+```python
+posthog = Posthog("phc_...", secret_key="phs_...")
+
+result = posthog.emails.send(
+ to="user@example.com",
+ distinct_id="user_123",
+ template_key="password-reset",
+ subject="Reset your password",
+ html=rendered_html,
+)
+
+# "delivered", "bounced", ...
+status = posthog.emails.get(result.id).status
+```
+
+You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secret_key`. Set `app_host` to your PostHog app URL when you self-host PostHog or send through a reverse proxy.
+
+PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too.
+
+A send with your own content also takes `from_`, `reply_to` and `capture_engagement=False`. Set `migrated_from` on the client to tell PostHog which email provider you moved from. A failed call raises an `EmailError` with PostHog's `code` and the HTTP `status_code`.
+
+Docs: [Send transactional emails with Python](https://posthog.com/docs/workflows/transactional-emails/python) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).
diff --git a/posthog/async_client.py b/posthog/async_client.py
index 72f7cd2fe..48de0abd6 100644
--- a/posthog/async_client.py
+++ b/posthog/async_client.py
@@ -137,6 +137,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")
@@ -181,10 +182,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,10 +270,10 @@ def __init__(
@property
def emails(self) -> AsyncEmails:
"""
- Send transactional email from PostHog email templates, and read the
- status of each email sent with ``get``.
+ Send transactional email from PostHog email templates or from content
+ in your code, and read the status of each email sent with ``get``.
- Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
+ Needs ``secret_key``, a project secret API key with the ``transactional_email: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
@@ -294,6 +298,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 ce5eb3352..87ba50705 100644
--- a/posthog/client.py
+++ b/posthog/client.py
@@ -737,6 +737,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.
@@ -797,6 +798,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.
@@ -1074,10 +1077,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
@@ -1256,10 +1262,10 @@ def _request_identity_kwargs(self) -> Dict[str, str]:
@property
def emails(self) -> Emails:
"""
- Send transactional email from PostHog email templates, and read the
- status of each email sent with ``get``.
+ Send transactional email from PostHog email templates or from content
+ in your code, and read the status of each email sent with ``get``.
- Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
+ Needs ``secret_key``, a project secret API key with the ``transactional_email: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
@@ -1285,6 +1291,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 c5bb6cec8..c53528c7c 100644
--- a/posthog/emails.py
+++ b/posthog/emails.py
@@ -1,11 +1,13 @@
-"""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:
1. In PostHog, open the email template under Workflows, make it transactional,
and copy its ID. ``template_id`` is that UUID.
-2. Create a project secret API key (``phs_...``) with Workflow write access
- (``hog_flow:write``) in your project settings. The project API key
+2. Create a project secret API key (``phs_...``) with the "Send transactional
+ email" preset (``transactional_email:write``) in your project settings. Add
+ ``transactional_email:read`` too if you read email statuses with a key that
+ can't send. Keys made with ``hog_flow:write`` still work. The project API key
(``phc_...``) captures events but cannot send email.
3. Send from the client you already use, or from :class:`Emails` in a process
that only sends email:
@@ -22,6 +24,15 @@
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",
+)
```
``posthog.emails`` above is the client's property. For async code, use
@@ -44,13 +55,17 @@
: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``.
- ``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``.
+ wrong, or it lacks ``transactional_email:write``.
- ``not_found`` (404): the template ID is wrong, or transactional email is not
enabled for the project.
- ``client_disabled``: the client is disabled, so it sends nothing.
@@ -58,9 +73,9 @@
Status: ``emails.get(message_id)`` reads what happened to a sent email, for
30 days after the send, as an :class:`EmailStatus`. A key with only
-``hog_flow:read`` can read statuses but not send. ``get`` raises ``not_found``
-(404) for an unknown ID, an email older than 30 days, or for a few seconds
-after the send.
+``transactional_email:read`` can read statuses but not send. ``get`` raises
+``not_found`` (404) for an unknown ID, an email older than 30 days, or for a
+few seconds after the send.
Cleanup: ``shutdown()`` on the client closes its email connections. Close a
standalone :class:`Emails` with ``close()`` or a ``with`` block.
@@ -85,10 +100,21 @@
from dataclasses import dataclass
from datetime import datetime
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 (
@@ -102,19 +128,23 @@
__all__ = [
"AsyncEmails",
+ "CodeEmailArgs",
"EmailError",
"EmailStatus",
"EmailWarning",
"Emails",
"SendEmailResult",
+ "TemplateEmailArgs",
]
log = logging.getLogger("posthog")
_SEND_PATH = "/api/projects/@current/messaging/send/email/"
_STATUS_PATH = "/api/projects/@current/messaging/emails/{message_id}/"
+_TEMPLATE_KEY = re.compile(r"[a-zA-Z0-9_-]{1,400}")
_MAX_RETRY_DELAY = 30.0
_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"})
@@ -168,6 +198,85 @@ class SendEmailResult:
warnings: tuple[EmailWarning, ...] = ()
+class _RecipientArgs(TypedDict):
+ to: str
+ distinct_id: NotRequired[Optional[str]]
+ idempotency_key: NotRequired[Optional[str]]
+ max_retries: NotRequired[Optional[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: Overrides the client's maximum number of retries for this
+ send. ``0`` turns retries off.
+ """
+
+ template_id: Union[str, UUID]
+ template_data: NotRequired[Optional[Mapping[str, Any]]]
+
+
+class CodeEmailArgs(_RecipientArgs):
+ """A send of email content that your code rendered for one recipient, named by a key.
+
+ The content goes out as is. To send a React email, render it to HTML in
+ your app first.
+
+ An optional field passed as ``None`` counts as left out.
+
+ 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. An empty list sends no reply-to.
+ capture_engagement: Capture the email's opens and clicks as events of
+ the recipient. Defaults to ``True``. A project whose admin turned
+ engagement capture off captures nothing, whatever the send asks. A
+ template made in PostHog follows the project's setting, so the
+ template form doesn't take it.
+ 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: Overrides the client's maximum number of retries for this
+ send. ``0`` turns retries off.
+ """
+
+ template_key: str
+ subject: str
+ html: NotRequired[Optional[str]]
+ text: NotRequired[Optional[str]]
+ from_: NotRequired[Optional[str]]
+ reply_to: NotRequired[Optional[Union[str, Sequence[str]]]]
+ capture_engagement: NotRequired[Optional[bool]]
+
+
+_OPTIONAL_CODE_FIELDS = frozenset(
+ {
+ "html",
+ "text",
+ "from_",
+ "reply_to",
+ "capture_engagement",
+ }
+)
+
+
@dataclass(frozen=True)
class EmailStatus:
"""What happened to an email that ``send()`` queued.
@@ -202,6 +311,114 @@ class _PreparedSend:
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.")
+ form = TemplateEmailArgs if is_template_form else CodeEmailArgs
+ unexpected = sorted(set(args) - set(form.__annotations__))
+ if unexpected:
+ raise TypeError(
+ 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 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"),
+ args.get("max_retries"),
+ )
+ return _prepare_code_send(args)
+
+
+def _prepare_code_send(args: Mapping[str, Any]) -> _PreparedSend:
+ args = {
+ name: value
+ for name, value in args.items()
+ if value is not None or name not in _OPTIONAL_CODE_FIELDS
+ }
+ if "subject" not in args:
+ raise TypeError("send() is missing the argument subject.")
+ to, distinct_id, idempotency_key = _recipient(
+ args["to"], args.get("distinct_id"), args.get("idempotency_key")
+ )
+ max_retries = _retry_limit(args.get("max_retries"))
+ 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."
+ )
+ 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.")
+ if not (content.get("html") or content.get("text")):
+ raise ValueError("html or text must be a nonempty string.")
+
+ payload: dict[str, Any] = {"to": to, "template_key": template_key, **content}
+ if distinct_id is not None:
+ payload["distinct_id"] = distinct_id
+ payload.update(_sender(args))
+ return _PreparedSend(
+ path=_SEND_PATH,
+ body=_encode(payload),
+ idempotency_key=idempotency_key,
+ max_retries=max_retries,
+ )
+
+
+def _sender(args: Mapping[str, Any]) -> dict[str, Any]:
+ sender: dict[str, Any] = {}
+ if "from_" in args:
+ sender["from"] = _nonempty_text(args, "from_")
+ reply_to = _addresses("reply_to", args["reply_to"]) if "reply_to" in args else []
+ if reply_to:
+ sender["reply_to"] = reply_to
+ capture_engagement = args.get("capture_engagement", True)
+ if not isinstance(capture_engagement, bool):
+ raise TypeError("capture_engagement must be True or False.")
+ sender["capture_engagement"] = capture_engagement
+ return sender
+
+
+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.")
+ if not all(address.strip() for address in addresses):
+ raise ValueError(f"{field} must not hold a blank address.")
+ return list(addresses)
+
+
+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.")
+ 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.")
+ 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(
to: str,
template_id: Union[str, UUID],
@@ -210,10 +427,8 @@ def _prepare_send(
idempotency_key: Optional[str],
max_retries: Optional[int] = None,
) -> _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.")
+ to, distinct_id, idempotency_key = _recipient(to, distinct_id, idempotency_key)
+ max_retries = _retry_limit(max_retries)
try:
template_uuid = (
template_id if isinstance(template_id, UUID) else UUID(template_id)
@@ -222,6 +437,29 @@ def _prepare_send(
raise ValueError("template_id must be the template's UUID.") from None
if template_data is not None and not isinstance(template_data, Mapping):
raise TypeError("template_data must be a mapping.")
+
+ payload: dict[str, Any] = {
+ "template_id": str(template_uuid),
+ "to": to,
+ "message_data": clean(dict(template_data or {})),
+ }
+ if distinct_id is not None:
+ payload["distinct_id"] = distinct_id
+ return _PreparedSend(
+ path=_SEND_PATH,
+ body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"),
+ idempotency_key=idempotency_key,
+ max_retries=max_retries,
+ )
+
+
+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.")
+ if not to.strip():
+ raise ValueError("to must not be empty.")
if distinct_id is not None and (
not isinstance(distinct_id, str) or not distinct_id
):
@@ -234,26 +472,17 @@ def _prepare_send(
raise ValueError(
"idempotency_key must be 1 to 255 visible ASCII characters without spaces."
)
+ return to, distinct_id, idempotency_key
+
+
+def _retry_limit(max_retries: Any) -> Optional[int]:
if max_retries is not None and (
isinstance(max_retries, bool)
or not isinstance(max_retries, int)
or max_retries < 0
):
raise ValueError("max_retries must be a nonnegative integer.")
-
- payload: dict[str, Any] = {
- "template_id": str(template_uuid),
- "to": to,
- "message_data": clean(dict(template_data or {})),
- }
- if distinct_id is not None:
- payload["distinct_id"] = distinct_id
- return _PreparedSend(
- path=_SEND_PATH,
- body=json.dumps(payload, cls=DatetimeSerializer).encode("utf-8"),
- idempotency_key=idempotency_key,
- max_retries=max_retries,
- )
+ return max_retries
def _status_path(message_id: Union[str, UUID]) -> str:
@@ -266,7 +495,9 @@ def _status_path(message_id: Union[str, UUID]) -> str:
return _STATUS_PATH.format(message_id=message_uuid)
-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))
@@ -280,6 +511,12 @@ def _validate_options(timeout: float, max_retries: int) -> None:
or max_retries < 0
):
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]:
@@ -449,24 +686,33 @@ 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,
+ migrated_from: Optional[str],
+ *,
+ has_body: bool = False,
+ idempotency_key: Optional[str] = None,
+) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {secret_key}",
"Accept": "application/json",
"User-Agent": f"posthog-python/{VERSION}",
}
- if idempotency_key is not None:
+ if has_body:
headers["Content-Type"] = "application/json"
+ 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
_SEND_NOT_DONE = "no email was sent"
_GET_NOT_DONE = "no email status was read"
-_SEND_KEY_NEEDED = "Sending email needs secret_key, a project secret API key with the hog_flow:write scope."
+_SEND_KEY_NEEDED = "Sending email needs secret_key, a project secret API key with the transactional_email:write scope."
_GET_KEY_NEEDED = (
"Reading an email status needs secret_key, a project secret API key with the "
- "hog_flow:read or hog_flow:write scope."
+ "transactional_email:read or transactional_email:write scope."
)
_ResultT = TypeVar("_ResultT")
_Parse = Callable[[int, Any, Optional[str]], _ResultT]
@@ -491,13 +737,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
@@ -526,7 +774,7 @@ def _secret_key(self, key_needed: str) -> 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
@@ -536,7 +784,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__(
@@ -548,6 +797,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,
@@ -555,22 +805,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,
- max_retries: Optional[int] = 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.
@@ -608,29 +861,40 @@ 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, max_retries
- )
+ prepared = _prepare(kwargs)
finish = self._start(_SEND_NOT_DONE)
try:
- headers = _headers(
- self._secret_key(_SEND_KEY_NEEDED), prepared.idempotency_key
- )
- result = self._call(
- "POST",
- prepared.path,
- prepared.body,
- headers,
- _parse_response,
- prepared.max_retries,
- )
+ result = self._deliver(prepared, self._secret_key(_SEND_KEY_NEEDED))
finally:
finish()
_log_warnings(result)
return result
+ def _deliver(self, prepared: _PreparedSend, secret_key: str) -> SendEmailResult:
+ headers = _headers(
+ secret_key,
+ self.migrated_from,
+ has_body=True,
+ idempotency_key=prepared.idempotency_key,
+ )
+ return self._call(
+ "POST",
+ prepared.path,
+ prepared.body,
+ headers,
+ _parse_response,
+ prepared.max_retries,
+ )
+
def get(self, message_id: Union[str, UUID]) -> EmailStatus:
"""Read what happened to an email that :meth:`send` queued.
@@ -658,7 +922,7 @@ def get(self, message_id: Union[str, UUID]) -> EmailStatus:
path = _status_path(message_id)
finish = self._start(_GET_NOT_DONE)
try:
- headers = _headers(self._secret_key(_GET_KEY_NEEDED), None)
+ headers = _headers(self._secret_key(_GET_KEY_NEEDED), self.migrated_from)
return self._call("GET", path, None, headers, _parse_status_response)
finally:
finish()
@@ -753,6 +1017,7 @@ def __init__(
max_retries: int = 2,
disabled: bool = False,
http_client: Any = None,
+ migrated_from: Optional[str] = None,
) -> None:
super().__init__(
secret_key,
@@ -760,21 +1025,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,
- max_retries: Optional[int] = 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`.
@@ -789,27 +1052,33 @@ async def send(
)
```
"""
- prepared = _prepare_send(
- to, template_id, template_data, distinct_id, idempotency_key, max_retries
- )
+ prepared = _prepare(kwargs)
finish = self._start(_SEND_NOT_DONE)
try:
- headers = _headers(
- self._secret_key(_SEND_KEY_NEEDED), prepared.idempotency_key
- )
- result = await self._call(
- "POST",
- prepared.path,
- prepared.body,
- headers,
- _parse_response,
- prepared.max_retries,
- )
+ result = await self._deliver(prepared, self._secret_key(_SEND_KEY_NEEDED))
finally:
finish()
_log_warnings(result)
return result
+ async def _deliver(
+ self, prepared: _PreparedSend, secret_key: str
+ ) -> SendEmailResult:
+ headers = _headers(
+ secret_key,
+ self.migrated_from,
+ has_body=True,
+ idempotency_key=prepared.idempotency_key,
+ )
+ return await self._call(
+ "POST",
+ prepared.path,
+ prepared.body,
+ headers,
+ _parse_response,
+ prepared.max_retries,
+ )
+
async def get(self, message_id: Union[str, UUID]) -> EmailStatus:
"""Read what happened to an email that :meth:`send` queued.
@@ -819,7 +1088,7 @@ async def get(self, message_id: Union[str, UUID]) -> EmailStatus:
path = _status_path(message_id)
finish = self._start(_GET_NOT_DONE)
try:
- headers = _headers(self._secret_key(_GET_KEY_NEEDED), None)
+ headers = _headers(self._secret_key(_GET_KEY_NEEDED), self.migrated_from)
return await self._call("GET", path, None, headers, _parse_status_response)
finally:
finish()
@@ -915,10 +1184,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
@@ -930,6 +1204,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 c0aeac2e8..eaf1c0949 100644
--- a/posthog/test/test_emails.py
+++ b/posthog/test/test_emails.py
@@ -176,6 +176,7 @@ 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"})
@@ -188,6 +189,35 @@ def test_client_email_options_set_the_host_timeout_and_retries():
request.assert_called_once()
assert request.call_args.args[1].startswith("https://posthog.example.com/api/")
assert request.call_args.kwargs["timeout"] == 3
+ sent = request.call_args.kwargs["auth"](
+ requests.Request("POST", "https://posthog.example.com/").prepare()
+ )
+ assert sent.headers["X-PostHog-Migrated-From"] == "resend"
+
+
+def test_names_the_provider_the_client_moved_from_on_every_request():
+ emails, adapter = emails_with([accepted(), status_reply()], migrated_from="resend")
+
+ send(emails)
+ emails.get(MESSAGE_ID)
+
+ assert [
+ request.headers["X-PostHog-Migrated-From"] for request in adapter.requests
+ ] == [
+ "resend",
+ "resend",
+ ]
+
+
+@parameterized.expand([("space", "re send"), ("not_text", 1), ("empty", "")])
+def test_rejects_a_provider_name_that_is_not_a_header_value(_name, migrated_from):
+ with pytest.raises(ValueError):
+ Posthog(
+ "phc_test",
+ secret_key=SECRET_KEY,
+ sync_mode=True,
+ migrated_from=migrated_from,
+ )
@parameterized.expand(
@@ -282,6 +312,18 @@ def test_waits_for_retry_after_and_gives_up_on_a_longer_one(no_sleep):
("client_option", {"max_retries": 0}, {}, 1),
("per_send_override", {}, {"max_retries": 0}, 1),
("per_send_override_above_client", {"max_retries": 0}, {"max_retries": 1}, 2),
+ (
+ "code_send_override",
+ {},
+ {
+ "template_id": None,
+ "template_key": "password-reset",
+ "subject": "Reset",
+ "html": "Reset
",
+ "max_retries": 0,
+ },
+ 1,
+ ),
]
)
def test_max_retries_zero_leaves_retries_to_the_caller(
@@ -290,9 +332,12 @@ def test_max_retries_zero_leaves_retries_to_the_caller(
emails, adapter = emails_with(
[rejected(503, "delivery_unavailable")] * 3, **client_options
)
+ args = {"to": "user@example.com", "template_id": TEMPLATE_ID, **send_options}
with pytest.raises(EmailError):
- send(emails, **send_options)
+ emails.send(
+ **{name: value for name, value in args.items() if value is not None}
+ )
assert len(adapter.requests) == requests_made
@@ -572,3 +617,185 @@ def handler(request: httpx.Request) -> httpx.Response:
assert [(request.method, str(request.url)) for request in seen] == [
("GET", STATUS_URL)
] * 2
+
+
+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,
+ "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_reply_to_left_out",
+ {"html": HTML, "reply_to": []},
+ {"html": HTML},
+ ),
+ (
+ "none_as_left_out",
+ {
+ "html": HTML,
+ "text": None,
+ "from_": None,
+ "reply_to": None,
+ "capture_engagement": None,
+ "distinct_id": None,
+ },
+ {"html": HTML},
+ ),
+ ]
+)
+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", 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",
+ "capture_engagement": True,
+ **extra_body,
+ }
+
+
+@pytest.mark.asyncio
+async def test_async_client_posts_rendered_content_and_names_the_provider():
+ seen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ return httpx.Response(202, json={"message_id": "msg_1", "replayed": True})
+
+ client = AsyncPosthog("phc_test", secret_key=SECRET_KEY, migrated_from="resend")
+ client.emails._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+
+ result = await client.emails.send(
+ to="user@example.com",
+ template_key="password-reset",
+ subject="Reset your password",
+ html=HTML,
+ text="Reset it here.",
+ from_="Acme ",
+ reply_to="help@example.com",
+ capture_engagement=False,
+ distinct_id="user_123",
+ idempotency_key="reset:user_123",
+ )
+ await client.shutdown()
+
+ assert (result.id, result.replayed) == ("msg_1", True)
+ [request] = seen
+ assert (request.method, str(request.url)) == ("POST", SEND_URL)
+ assert request.headers["Authorization"] == f"Bearer {SECRET_KEY}"
+ assert request.headers["Idempotency-Key"] == "reset:user_123"
+ assert request.headers["X-PostHog-Migrated-From"] == "resend"
+ assert json.loads(request.content) == {
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ "html": HTML,
+ "text": "Reset it here.",
+ "from": "Acme ",
+ "reply_to": ["help@example.com"],
+ "capture_engagement": False,
+ "distinct_id": "user_123",
+ }
+
+
+@parameterized.expand(
+ [
+ ("both_forms", {"template_id": TEMPLATE_ID, "html": HTML}, TypeError),
+ ("no_form", {"template_key": None}, TypeError),
+ ("no_content", {}, TypeError),
+ ("no_subject", {"subject": None, "html": HTML}, TypeError),
+ ("subject_not_text", {"subject": 1, "html": HTML}, TypeError),
+ ("from_not_text", {"html": HTML, "from_": 1}, TypeError),
+ ("from_keyword", {"html": HTML, "from": "a@example.com"}, TypeError),
+ (
+ "key_with_space",
+ {"template_key": "reset password", "html": HTML},
+ ValueError,
+ ),
+ (
+ "key_with_trailing_newline",
+ {"template_key": "welcome\n", "html": HTML},
+ ValueError,
+ ),
+ ("key_too_long", {"template_key": "k" * 401, "html": HTML}, ValueError),
+ ("empty_html", {"html": ""}, ValueError),
+ ("empty_html_and_text", {"html": "", "text": ""}, ValueError),
+ ("blank_reply_to", {"html": HTML, "reply_to": " "}, ValueError),
+ ("reply_to_not_text", {"html": HTML, "reply_to": 123}, TypeError),
+ (
+ "template_data_on_code_form",
+ {"html": HTML, "template_data": {"first_name": "Jane"}},
+ TypeError,
+ ),
+ ("cc_on_code_form", {"html": HTML, "cc": "a@example.com"}, TypeError),
+ (
+ "capture_engagement_on_template_form",
+ {
+ "template_id": TEMPLATE_ID,
+ "template_key": None,
+ "subject": None,
+ "capture_engagement": False,
+ },
+ TypeError,
+ ),
+ ]
+)
+def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error):
+ emails, adapter = emails_with([])
+ args = {
+ "to": "user@example.com",
+ "template_key": "password-reset",
+ "subject": "Reset your password",
+ **overrides,
+ }
+
+ with pytest.raises(error):
+ emails.send(
+ **{name: value for name, value in args.items() if value is not None}
+ )
+
+ assert adapter.requests == []
diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt
index 9db790cea..bfffd5d64 100644
--- a/references/public_api_snapshot.txt
+++ b/references/public_api_snapshot.txt
@@ -760,6 +760,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()
@@ -830,6 +831,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
@@ -886,6 +888,13 @@ attribute posthog.debug = False
attribute posthog.default_client = None
attribute posthog.disable_geoip = True
attribute posthog.disabled = False
+attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]]
+attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[str]]
+attribute posthog.emails.CodeEmailArgs.html: NotRequired[Optional[str]]
+attribute posthog.emails.CodeEmailArgs.reply_to: NotRequired[Optional[Union[str, Sequence[str]]]]
+attribute posthog.emails.CodeEmailArgs.subject: str
+attribute posthog.emails.CodeEmailArgs.template_key: str
+attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[str]]
attribute posthog.emails.EmailError.code = code
attribute posthog.emails.EmailError.docs_url = docs_url
attribute posthog.emails.EmailError.field = field
@@ -906,6 +915,8 @@ attribute posthog.emails.EmailWarning.message: str
attribute posthog.emails.SendEmailResult.id: str
attribute posthog.emails.SendEmailResult.replayed: bool = False
attribute posthog.emails.SendEmailResult.warnings: tuple[EmailWarning, ...] = ()
+attribute posthog.emails.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
@@ -1322,21 +1333,23 @@ 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: float = 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: float = 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: float = 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: float = 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: float = 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: float = 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: float = 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: float = 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: float = 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.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.EmailStatus(id: str, status: str, created_at: datetime, status_at: datetime, reason: Optional[str] = None)
class posthog.emails.EmailWarning(code: str, message: str, fix: Optional[str] = None)
-class posthog.emails.Emails(secret_key: Optional[str], *, host: Optional[str] = None, timeout: float = 15, max_retries: int = 2, disabled: bool = False, session: Optional[requests.Session] = None)
+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, replayed: bool = False, warnings: tuple[EmailWarning, ...] = ())
+class posthog.emails.TemplateEmailArgs
class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS)
class posthog.exception_utils.AnnotatedValue(value, metadata)
class posthog.exception_utils.VariableSizeLimiter(max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT)
@@ -1836,10 +1849,10 @@ 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.get(message_id: Union[str, UUID]) -> EmailStatus
-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, max_retries: Optional[int] = None) -> SendEmailResult
+method posthog.emails.AsyncEmails.send(**kwargs: Any) -> SendEmailResult
method posthog.emails.Emails.close() -> None
method posthog.emails.Emails.get(message_id: Union[str, UUID]) -> EmailStatus
-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, max_retries: Optional[int] = 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)