diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 7b46c0af..67da012e 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,18 @@ 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_="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"}]) +_ = (template_send, rendered_send, local_template_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="Hi
") + _ = sent + async def offline_evaluation_types( sync_client: OfflineEvaluations, async_client: AsyncOfflineEvaluations @@ -64,15 +77,25 @@ 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
", attachments=[{"filename": "a.txt", "content": [104, 105]}]) # 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 +139,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 +148,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 00000000..df27efc6 --- /dev/null +++ b/.sampo/changesets/emails-code-form.md @@ -0,0 +1,9 @@ +--- +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 local template needs `html`. 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 `max_retries` like a template send. Set `migrated_from`, such as `"resend"`, on the client or on `Emails` to send it with every email request. + +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. An optional field of a code send passed as `None` counts as left out. diff --git a/posthog/async_client.py b/posthog/async_client.py index 57ed13e2..787705f9 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 ) @@ -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 @@ -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 diff --git a/posthog/client.py b/posthog/client.py index 388e20b2..fda66cf2 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -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. @@ -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. @@ -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 @@ -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 @@ -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 diff --git a/posthog/emails.py b/posthog/emails.py index 72b1ca3e..c65af278 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -1,4 +1,4 @@ -"""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: @@ -22,6 +22,26 @@ 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", +) + +# A template in your code. PostHog fills in the variables, and the client +# uploads the template whenever its content changes. +posthog.emails.send( + to="user@example.com", + template_key="welcome", + subject="Welcome, {{ variables.name }}", + html=welcome_html, + template_data={"name": "Ada"}, + distinct_id="user_123", +) ``` ``posthog.emails`` above is the client's property. For async code, use @@ -44,6 +64,10 @@ :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: @@ -74,6 +98,8 @@ from __future__ import annotations import asyncio +import base64 +import hashlib import importlib import json import logging @@ -85,10 +111,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,21 +139,30 @@ __all__ = [ "AsyncEmails", + "CodeEmailArgs", + "EmailAttachment", "EmailError", "EmailStatus", + "EmailTag", "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}/" +_UPLOAD_PATH = "/api/projects/@current/messaging_templates/by_key/{template_key}/" +_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}$") +_HEADER_TOKEN = re.compile(r"[\x21-\x7e]+") _IN_FLIGHT_CODES = frozenset({"idempotency_key_in_flight"}) _QUOTA_CODES = frozenset({"quota_limited"}) +_TEMPLATE_CONFLICTS = frozenset({"template_outdated", "template_edited_in_posthog"}) class EmailError(Exception): @@ -168,6 +214,142 @@ class SendEmailResult: warnings: tuple[EmailWarning, ...] = () +class EmailTag(TypedDict): + """A name and value that PostHog adds to the email's engagement events as ``$email_tags``.""" + + name: str + value: str + + +class EmailAttachment(TypedDict): + """A file sent with the email. + + Args: + filename: The file name the recipient sees, such as ``invite.ics``. + content: The file as ``bytes``, ``bytearray`` or ``memoryview``, or + already Base64 encoded as a string. + content_type: The MIME type. Without it, the type follows the file name. + content_id: Shows the file inline: reference it in ``html`` as + ``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,466 @@ 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_": "AcmeHi {{ variables.first_name }}
", +} +# The server's content_hash v1 test vectors, shared with posthog-node. +WELCOME_HASH = "v1:f74cd913f3aa2e5667a588f2865634e56693df77793ca594d18e9b5f89e3d2e6" +WELCOME_CRLF_TEXT_HASH = ( + "v1:8112876ee1b2dd7327f571b04b546ed057bfff5a904d9f0ae507b6a449ab6453" +) +UPLOAD_URL = ( + "https://us.posthog.com/api/projects/@current/messaging_templates/by_key/welcome/" +) + + +def send_local_template(emails: Emails, **overrides: Any): + return emails.send( + **{ + "to": "user@example.com", + **WELCOME, + "template_data": {"first_name": "Jane"}, + **overrides, + } + ) + + +@parameterized.expand( + [ + ("html_only", {}, WELCOME_HASH), + ("crlf_text", {"text": "\r\n\r"}, WELCOME_CRLF_TEXT_HASH), + ("empty_text", {"text": ""}, WELCOME_HASH), + ] +) +def test_a_local_template_is_sent_by_key_and_content_hash(_name, overrides, hash): + emails, adapter = emails_with([accepted("msg_1")]) + + result = send_local_template( + emails, from_="AcmeHi {{ variables.first_name }}
", + "text": "Hi {{ variables.first_name }}", + "if_not_superseded": True, + "if_code_managed": True, + } + assert "Idempotency-Key" not in upload.headers + assert upload.headers["Authorization"] == f"Bearer {SECRET_KEY}" + assert second_send.body == first_send.body + assert ( + second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"] + ) + + +@parameterized.expand( + [ + ( + "refused_upload", + rejected(409, "template_edited_in_posthog"), + "template_edited_in_posthog", + 2, + ), + ("outdated_again", None, "template_outdated", 3), + ] +) +def test_a_local_template_uploads_at_most_once_per_send( + _name, upload_reply, code, requests_made +): + replies = ( + [rejected(409, "template_outdated"), upload_reply] + if upload_reply + else [ + rejected(409, "template_outdated"), + uploaded(), + rejected(409, "template_outdated"), + ] + ) + emails, adapter = emails_with(replies) + + with pytest.raises(EmailError) as raised: + send_local_template(emails) + + assert (raised.value.status_code, raised.value.code, raised.value.name) == ( + 409, + code, + "validation_error", + ) + assert len(adapter.requests) == requests_made + + +def test_rendered_content_is_never_uploaded(): + emails, adapter = emails_with([rejected(409, "template_outdated")]) + + with pytest.raises(EmailError): + send_code(emails, html=HTML) + + assert [request.method for request in adapter.requests] == ["POST"] + + +@pytest.mark.asyncio +async def test_async_uploads_an_outdated_local_template_then_sends_again(): + seen: list[httpx.Request] = [] + replies = [ + httpx.Response(409, json={"code": "template_outdated"}), + httpx.Response(201, json={"id": "tpl_1", "result": "created"}), + httpx.Response(202, json={"message_id": "msg_1"}), + ] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return replies.pop(0) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await AsyncEmails(SECRET_KEY, http_client=client).send( + to="user@example.com", **WELCOME, template_data={"first_name": "Jane"} + ) + + assert result.id == "msg_1" + assert [(request.method, str(request.url)) for request in seen] == [ + ("POST", SEND_URL), + ("PUT", UPLOAD_URL), + ("POST", SEND_URL), + ] + first_send, upload, second_send = seen + assert json.loads(first_send.content)["template"] == { + "key": "welcome", + "hash": WELCOME_HASH, + } + assert "Idempotency-Key" not in upload.headers + assert second_send.content == first_send.content + assert ( + second_send.headers["Idempotency-Key"] == first_send.headers["Idempotency-Key"] + ) + + +@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_cc", {"html": HTML, "cc": " "}, ValueError), + ("cc_not_text", {"html": HTML, "cc": 123}, TypeError), + ("template_data_list", {"html": HTML, "template_data": [1]}, TypeError), + ( + "local_template_without_html", + {"text": "Hi", "template_data": {"first_name": "Jane"}}, + ValueError, + ), + ( + "local_template_with_empty_html", + {"html": "", "text": "Hi", "template_data": {"first_name": "Jane"}}, + ValueError, + ), + ( + "capture_engagement_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "capture_engagement": False, + }, + TypeError, + ), + ( + "cc_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "cc": "a@example.com", + }, + 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 == [] + + +COPIES_HEADERS_AND_TAGS = { + "cc": "manager@example.com", + "bcc": ["audit@example.com", "archive@example.com"], + "headers": {"X-Entity-Ref-ID": "order-123"}, + "tags": [{"name": "category", "value": "password_reset"}], +} +COPIES_HEADERS_AND_TAGS_BODY = { + "cc": ["manager@example.com"], + "bcc": ["audit@example.com", "archive@example.com"], + "headers": {"X-Entity-Ref-ID": "order-123"}, + "tags": [{"name": "category", "value": "password_reset"}], +} + + +def test_rendered_content_carries_copies_headers_and_tags(): + emails, adapter = emails_with([accepted()]) + + send_code(emails, html=HTML, **COPIES_HEADERS_AND_TAGS) + + [request] = adapter.requests + assert json.loads(request.body) == { + "to": "user@example.com", + "template_key": "password-reset", + "subject": "Reset your password", + "html": HTML, + "capture_engagement": True, + **COPIES_HEADERS_AND_TAGS_BODY, + } + + +def test_a_server_that_does_not_take_a_field_refuses_the_send_and_names_it(): + refused = { + "type": "invalid_request", + "code": "invalid_request", + "detail": "cc: PostHog doesn't take this field on this route.", + "attr": "cc", + } + emails, adapter = emails_with([(400, refused, {}), accepted()]) + + with pytest.raises(EmailError) as raised: + send_code(emails, html=HTML, cc="manager@example.com") + + error = raised.value + assert (error.name, error.code, error.field) == ( + "validation_error", + "invalid_request", + "cc", + ) + assert len(adapter.requests) == 1 + + +def test_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them(): + emails, adapter = emails_with( + [rejected(409, "template_outdated"), uploaded(), accepted()] + ) + + send_local_template(emails, **COPIES_HEADERS_AND_TAGS) + + first_send, upload, _ = adapter.requests + sent = json.loads(first_send.body) + assert {field: sent[field] for field in COPIES_HEADERS_AND_TAGS_BODY} == ( + COPIES_HEADERS_AND_TAGS_BODY + ) + assert not set(COPIES_HEADERS_AND_TAGS_BODY) & set(json.loads(upload.body)) + + +INVITE = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" + + +def test_attachments_are_sent_base64_encoded_once_across_retries(): + emails, adapter = emails_with([rejected(503, "delivery_unavailable"), accepted()]) + + send_code( + emails, + html=HTML, + attachments=[ + { + "filename": "invite.ics", + "content": INVITE, + "content_type": "text/calendar; method=REQUEST", + }, + {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}, + ], + ) + + first, retry = adapter.requests + assert json.loads(first.body)["attachments"] == [ + { + "filename": "invite.ics", + "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=", + "content_type": "text/calendar; method=REQUEST", + }, + {"filename": "logo.png", "content": "iVBORw0KGgo=", "content_id": "logo"}, + ] + assert retry.body == first.body + + +@parameterized.expand( + [ + ("bytearray", bytearray(INVITE)), + ("memoryview", memoryview(INVITE)), + ( + "strided_memoryview", + memoryview(bytes(b for byte in INVITE for b in (byte, 0)))[::2], + ), + ] +) +def test_attachment_content_from_any_bytes_buffer_is_base64_encoded(_name, content): + emails, adapter = emails_with([accepted()]) + + send_code( + emails, html=HTML, attachments=[{"filename": "invite.ics", "content": content}] + ) + + [request] = adapter.requests + assert json.loads(request.body)["attachments"] == [ + { + "filename": "invite.ics", + "content": "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=", + } + ] + + +@parameterized.expand( + [ + ("list_of_ints", {"filename": "a.txt", "content": [104, 105]}), + ("no_filename", {"content": b"104"}), + ("unknown_field", {"filename": "a.txt", "content": b"104", "path": "/tmp/a"}), + ] +) +def test_rejects_an_attachment_that_is_not_a_file(_name, attachment): + emails, adapter = emails_with([]) + + with pytest.raises(TypeError) as raised: + send_code(emails, html=HTML, attachments=[attachment]) + + assert "104" not in str(raised.value) + assert adapter.requests == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 3eadb81b..2eb20a58 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -758,6 +758,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() @@ -828,6 +829,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 @@ -884,6 +886,23 @@ attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True attribute posthog.disabled = False +attribute posthog.emails.CodeEmailArgs.attachments: NotRequired[Optional[Sequence[EmailAttachment]]] +attribute posthog.emails.CodeEmailArgs.bcc: NotRequired[Optional[Union[str, Sequence[str]]]] +attribute posthog.emails.CodeEmailArgs.capture_engagement: NotRequired[Optional[bool]] +attribute posthog.emails.CodeEmailArgs.cc: NotRequired[Optional[Union[str, Sequence[str]]]] +attribute posthog.emails.CodeEmailArgs.from_: NotRequired[Optional[str]] +attribute posthog.emails.CodeEmailArgs.headers: NotRequired[Optional[Mapping[str, 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.tags: NotRequired[Optional[Sequence[EmailTag]]] +attribute posthog.emails.CodeEmailArgs.template_data: NotRequired[Optional[Mapping[str, Any]]] +attribute posthog.emails.CodeEmailArgs.template_key: str +attribute posthog.emails.CodeEmailArgs.text: NotRequired[Optional[str]] +attribute posthog.emails.EmailAttachment.content: Union[bytes, bytearray, memoryview, str] +attribute posthog.emails.EmailAttachment.content_id: NotRequired[str] +attribute posthog.emails.EmailAttachment.content_type: NotRequired[str] +attribute posthog.emails.EmailAttachment.filename: str attribute posthog.emails.EmailError.code = code attribute posthog.emails.EmailError.docs_url = docs_url attribute posthog.emails.EmailError.field = field @@ -898,12 +917,16 @@ attribute posthog.emails.EmailStatus.id: str attribute posthog.emails.EmailStatus.reason: Optional[str] = None attribute posthog.emails.EmailStatus.status: str attribute posthog.emails.EmailStatus.status_at: datetime +attribute posthog.emails.EmailTag.name: str +attribute posthog.emails.EmailTag.value: str attribute posthog.emails.EmailWarning.code: str attribute posthog.emails.EmailWarning.fix: Optional[str] = None 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 @@ -1320,21 +1343,25 @@ 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.EmailAttachment 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.EmailTag 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) @@ -1833,10 +1860,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)