Skip to content
Closed
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,18 @@ span.end()

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

emails = Emails("phs_example", migrated_from="resend")
template_send: SendEmailResult = emails.send(to="[email protected]", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", template_data={"name": "Ada"}, capture_engagement=False, max_retries=0)
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]"])
local_template_send: SendEmailResult = emails.send(to="[email protected]", template_key="welcome", subject="Hi {{ variables.name }}", text="Hi", template_data={"name": "Ada"})
invite_send: SendEmailResult = emails.send(to="[email protected]", template_key="invite", subject="Invite", html="<p>Invite</p>", cc="[email protected]", bcc=["[email protected]"], headers={"X-Entity-Ref-ID": "order-123"}, tags=[{"name": "category", "value": "invite"}], attachments=[{"filename": "invite.ics", "content": b"BEGIN:VCALENDAR", "content_type": "text/calendar"}, {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}])
_ = (template_send, rendered_send, local_template_send, invite_send)


async def async_email_types() -> None:
sent: SendEmailResult = await AsyncEmails("phs_example").send(to="[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 +77,24 @@ async def offline_evaluation_types(
_ = config
PY

cat > "$tmp/invalid_offline_types.py" <<'PY'
cat > "$tmp/invalid_types.py" <<'PY'
# pyright: strict
from posthog.ai.evaluations import BooleanScorerConfig, CategoricalScorerConfig, NumericPassingRule, NumericScorerConfig, OfflineEvaluations
from posthog.emails import AsyncEmails, Emails

client = OfflineEvaluations(project_id=123, secret_key="phx_example")
NumericPassingRule(operator="gt", threshold=0.8) # expected-type-error
NumericScorerConfig(unknown_setting=True) # expected-type-error
CategoricalScorerConfig(selection_mode="single") # expected-type-error
client.scorers.create(name="Bad config", kind="numeric", config=BooleanScorerConfig(true_is_failure=True)) # expected-type-error
Emails("phs_example").send(to="[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_key="welcome", subject="Hi", html="<p>Hi</p>", attachments=[{"filename": "a.txt", "content": [104, 105]}]) # 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
7 changes: 7 additions & 0 deletions .sampo/changesets/emails-code-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
pypi/posthog: minor
---

Send transactional email from content in your code with `posthog.emails.send(template_key=..., subject=..., html=...)`. Without `template_data`, the content is sent as is. With `template_data`, the content is a template: PostHog fills in its variables, and the client uploads it by key whenever PostHog doesn't hold its content yet, then sends again with the same idempotency key. A send that mixes `template_id` and `template_key` fails type checks and raises `EmailValidationError`.

A code send also takes `cc`, `bcc`, `headers`, `tags` and `attachments`. An attachment's `content` can be `bytes`, `bytearray` or `memoryview`, which the client encodes as Base64, or a Base64 string.
5 changes: 5 additions & 0 deletions .sampo/changesets/emails-contract-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

`emails.send` follows the shared email SDK contract. An argument that fails a check raises `EmailValidationError`, an `EmailError` and a `ValueError` with the check's `code` and the argument in `field`. A disabled client raises `client_disabled`, and a client without a usable app host raises `missing_app_host`. A send takes `max_retries`, and a template send takes `capture_engagement`. Set `migrated_from`, such as `"resend"`, on the client to send it with every email request. A `Retry-After` over 30 seconds waits 30 seconds instead of failing the send. `SendEmailResult` has no `replayed` field.
12 changes: 9 additions & 3 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -260,13 +264,14 @@ def __init__(
@property
def emails(self) -> AsyncEmails:
"""
Send transactional email from PostHog email templates.
Send transactional email from PostHog email templates or from content in your code.

Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog
Cloud app host of ``host``. ``email_request_timeout_seconds`` and
``email_request_max_retries`` configure each send. A disabled or shut-down
client sends nothing. See ``posthog.emails`` for setup.
client sends nothing and raises ``client_disabled`` or ``client_closed``.
See ``posthog.emails`` for setup.

Examples:
```python
Expand All @@ -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

Expand Down
16 changes: 12 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1152,13 +1158,14 @@ def _request_identity_kwargs(self) -> Dict[str, str]:
@property
def emails(self) -> Emails:
"""
Send transactional email from PostHog email templates.
Send transactional email from PostHog email templates or from content in your code.

Needs ``secret_key``, a project secret API key with the ``hog_flow:write``
scope. Sends go to ``app_host``, or to the PostHog Cloud app host of
``host``. ``email_request_timeout_seconds`` and ``email_request_max_retries``
configure each send. A disabled or shut-down client sends nothing. See
``posthog.emails`` for setup.
configure each send. A disabled or shut-down client sends nothing and
raises ``client_disabled`` or ``client_closed``. See ``posthog.emails``
for setup.

Examples:
```python
Expand All @@ -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

Expand Down
Loading