Skip to content
Open
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
35 changes: 30 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,17 @@ 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="<p>Reset</p>", from_="Acme <security@example.com>", reply_to=["help@example.com"], capture_engagement=False, max_retries=0)
invite_send: SendEmailResult = emails.send(to="user@example.com", template_key="invite", subject="Invite", html="<p>Invite</p>", cc="team@example.com", bcc=["audit@example.com"], attachments=[{"filename": "invite.ics", "content": b"BEGIN:VCALENDAR", "content_type": "text/calendar"}, {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}])
_ = (template_send, rendered_send, invite_send)


async def async_email_types() -> None:
sent: SendEmailResult = await AsyncEmails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="<p>Hi</p>")
_ = sent


async def offline_evaluation_types(
sync_client: OfflineEvaluations, async_client: AsyncOfflineEvaluations
Expand All @@ -64,15 +76,28 @@ 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="<p>Hi</p>") # expected-type-error
Emails("phs_example").send(to="user@example.com", template_key="welcome", html="<p>Hi</p>") # 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="<p>Hi</p>", template_data={"name": "Ada"}) # expected-type-error
Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="<p>Hi</p>", attachments=[{"filename": "a.txt", "content": [104, 105]}]) # expected-type-error
Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="<p>Hi</p>", attachments=[{"filename": "a.txt", "content": b"hi", "path": "/tmp/a.txt"}]) # expected-type-error
Emails("phs_example").send(to="user@example.com", template_id="0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", attachments=[{"filename": "a.txt", "content": b"hi"}]) # 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"
Expand Down Expand Up @@ -116,20 +141,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
5 changes: 5 additions & 0 deletions .sampo/changesets/emails-code-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

A code send with `posthog.emails.send` takes `cc`, `bcc` and `attachments`. An attachment is a `filename` and its `content`, as bytes or a Base64 string, with an optional `content_type` and `content_id`. An attachment the client can't send raises `EmailError` with the code `invalid_attachment` before any request.
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=...)`. The client sends the content as is, so render a React email to HTML in your app first. A send that mixes `template_id` and `template_key` fails type checks and raises `TypeError`.

A send with `template_key` captures the email's opens and clicks unless it passes `capture_engagement=False`. A template made in PostHog follows the project's setting, so a `template_id` send doesn't take `capture_engagement`. A code send takes `from_`, `reply_to` and `max_retries`. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. An optional field of a code send passed as `None` counts as left out.
11 changes: 8 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,8 +264,8 @@ 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``
scope, and ``posthog[async]``. Sends go to ``app_host``, or to the PostHog
Expand All @@ -288,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
13 changes: 10 additions & 3 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,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 @@ -793,6 +794,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 @@ -1068,10 +1071,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 @@ -1250,8 +1256,8 @@ 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``
scope. Sends go to ``app_host``, or to the PostHog Cloud app host of
Expand Down Expand Up @@ -1279,6 +1285,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