Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions .github/scripts/check_strict_types.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,16 @@ span.end()

_ = (flag_value, all_flags, enabled, payload, evaluations, active)

emails = Emails("phs_example")
template_send: SendEmailResult = emails.send(to="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"})
rendered_send: SendEmailResult = emails.send(to="[email protected]", template_key="password-reset", subject="Reset", html="<p>Reset</p>", from_="Acme <[email protected]>", reply_to=["[email protected]"], capture_engagement=False, max_retries=0)
_ = (template_send, rendered_send)


async def async_email_types() -> None:
sent: SendEmailResult = await AsyncEmails("phs_example").send(to="[email protected]", template_key="welcome", subject="Hi", html="<p>Hi</p>")
_ = sent


async def offline_evaluation_types(
sync_client: OfflineEvaluations, async_client: AsyncOfflineEvaluations
Expand All @@ -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="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_key="welcome", subject="Hi", html="<p>Hi</p>") # expected-type-error
Emails("phs_example").send(to="[email protected]", template_key="welcome", html="<p>Hi</p>") # expected-type-error
Emails("phs_example").send(to="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", cc="[email protected]") # expected-type-error
Emails("phs_example").send(to="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", capture_engagement=False) # expected-type-error
Emails("phs_example").send(to="[email protected]", template_key="welcome", subject="Hi", html="<p>Hi</p>", template_data={"name": "Ada"}) # expected-type-error
Emails("phs_example").send(to="[email protected]", template_key="welcome", subject="Hi", html="<p>Hi</p>", cc="[email protected]") # expected-type-error


async def invalid_async_email() -> None:
await AsyncEmails("phs_example").send(to="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", subject="Hi") # expected-type-error
PY

"$tmp/.venv/bin/python" - <<'PY' > "$tmp/public_api_access.py"
Expand Down Expand Up @@ -116,20 +138,20 @@ 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'
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
28 changes: 28 additions & 0 deletions .sampo/changesets/emails-code-form.md
Original file line number Diff line number Diff line change
@@ -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="[email protected]",
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).
13 changes: 9 additions & 4 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
15 changes: 11 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading
Loading