diff --git a/.github/scripts/check_strict_types.sh b/.github/scripts/check_strict_types.sh index 4e41c9e8f..36935a0b7 100755 --- a/.github/scripts/check_strict_types.sh +++ b/.github/scripts/check_strict_types.sh @@ -44,7 +44,8 @@ _ = (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) +invite_send: SendEmailResult = emails.send(to="user@example.com", template_key="invite", subject="Invite", html="

Invite

", 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: @@ -90,7 +91,9 @@ Emails("phs_example").send(to="user@example.com", template_key="welcome", html=" 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 +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 +Emails("phs_example").send(to="user@example.com", template_key="welcome", subject="Hi", html="

Hi

", 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: diff --git a/.sampo/changesets/emails-code-attachments.md b/.sampo/changesets/emails-code-attachments.md new file mode 100644 index 000000000..20e4cfbba --- /dev/null +++ b/.sampo/changesets/emails-code-attachments.md @@ -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. diff --git a/posthog/emails.py b/posthog/emails.py index c53528c7c..60089816a 100644 --- a/posthog/emails.py +++ b/posthog/emails.py @@ -89,6 +89,8 @@ from __future__ import annotations import asyncio +import base64 +import binascii import importlib import json import logging @@ -129,6 +131,7 @@ __all__ = [ "AsyncEmails", "CodeEmailArgs", + "EmailAttachment", "EmailError", "EmailStatus", "EmailWarning", @@ -198,6 +201,25 @@ class SendEmailResult: warnings: tuple[EmailWarning, ...] = () +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``, which + the client encodes as Base64, or already Base64 encoded as a string. + PostHog can't fetch a file from a path or URL. + content_type: The MIME type. Without it, the type follows the file name. + content_id: Shows the file inline: reference it in ``html`` as + ```` with ``content_id`` ``logo``. + """ + + filename: str + content: Union[bytes, bytearray, memoryview, str] + content_type: NotRequired[str] + content_id: NotRequired[str] + + class _RecipientArgs(TypedDict): to: str distinct_id: NotRequired[Optional[str]] @@ -231,6 +253,10 @@ class CodeEmailArgs(_RecipientArgs): An optional field passed as ``None`` counts as left out. + A PostHog version that doesn't take a field yet, such as ``cc``, refuses + the send with an error that names the field in ``EmailError.field``. It + never drops the field and sends the rest. + Args: to: One recipient email address. template_key: Names the email, so its metrics, logs and status group @@ -242,6 +268,13 @@ class CodeEmailArgs(_RecipientArgs): 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. + cc: One address or a list of addresses that get a visible copy, at + most 10. + bcc: One address or a list of addresses that get a hidden copy, at + most 10. + attachments: Files sent with the email, at most 10 and 512 KB in + total. A file the client can't send raises :class:`EmailError` + with ``code`` ``invalid_attachment``. 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 @@ -263,15 +296,22 @@ class CodeEmailArgs(_RecipientArgs): text: NotRequired[Optional[str]] from_: NotRequired[Optional[str]] reply_to: NotRequired[Optional[Union[str, Sequence[str]]]] + cc: NotRequired[Optional[Union[str, Sequence[str]]]] + bcc: NotRequired[Optional[Union[str, Sequence[str]]]] + attachments: NotRequired[Optional[Sequence[EmailAttachment]]] capture_engagement: NotRequired[Optional[bool]] +_ATTACHMENT_FIELDS = frozenset(EmailAttachment.__annotations__) _OPTIONAL_CODE_FIELDS = frozenset( { "html", "text", "from_", "reply_to", + "cc", + "bcc", + "attachments", "capture_engagement", } ) @@ -378,9 +418,13 @@ 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 + for field in ("reply_to", "cc", "bcc"): + addresses = _addresses(field, args[field]) if field in args else [] + if addresses: + sender[field] = addresses + attachments = _attachments(args["attachments"]) if "attachments" in args else [] + if attachments: + sender["attachments"] = attachments capture_engagement = args.get("capture_engagement", True) if not isinstance(capture_engagement, bool): raise TypeError("capture_engagement must be True or False.") @@ -399,6 +443,83 @@ def _addresses(field: str, value: Any) -> list[str]: return list(addresses) +def _invalid_attachment(message: str, field: str) -> EmailError: + return EmailError( + name="validation_error", + code="invalid_attachment", + message=message, + field=field, + ) + + +def _attachments(attachments: Any) -> list[dict[str, str]]: + if isinstance( + attachments, (str, bytes, bytearray, memoryview, Mapping) + ) or not isinstance(attachments, Sequence): + raise _invalid_attachment("attachments must be a list of files.", "attachments") + return [_attachment(index, file) for index, file in enumerate(attachments)] + + +def _attachment(index: int, file: Any) -> dict[str, str]: + field = f"attachments__{index}" + if not isinstance(file, Mapping): + raise _invalid_attachment( + "An attachment is a mapping with filename and content. " + "Pass the file's bytes as its content.", + field, + ) + if "path" in file: + raise _invalid_attachment( + "PostHog can't fetch an attachment from a path or URL. " + "Read the file and pass its bytes as content instead.", + f"{field}__path", + ) + unknown = [name for name in file if name not in _ATTACHMENT_FIELDS] + if unknown: + raise _invalid_attachment( + "An attachment takes filename, content, content_type and content_id, " + f"not {unknown[0]}.", + f"{field}__{unknown[0]}", + ) + if not isinstance(file.get("filename"), str) or not file["filename"]: + raise _invalid_attachment( + "An attachment needs a filename, such as 'invoice.pdf'.", + f"{field}__filename", + ) + attachment = { + "filename": file["filename"], + "content": _base64_content(field, file.get("content")), + } + for name in ("content_type", "content_id"): + if name in file: + if not isinstance(file[name], str): + raise _invalid_attachment( + f"An attachment's {name} must be a string.", f"{field}__{name}" + ) + attachment[name] = file[name] + return attachment + + +def _base64_content(field: str, content: Any) -> str: + if isinstance(content, (bytes, bytearray, memoryview)): + return base64.b64encode(bytes(content)).decode("ascii") + if isinstance(content, str): + try: + base64.b64decode(content, validate=True) + except (binascii.Error, ValueError): + raise _invalid_attachment( + "An attachment's content string must be Base64. " + "Pass the file's bytes instead, such as text.encode() for text.", + f"{field}__content", + ) from None + return content + raise _invalid_attachment( + "An attachment needs content: bytes or a Base64 string. " + "Convert a list of byte values with bytes(content).", + f"{field}__content", + ) + + def _string(args: Mapping[str, Any], name: str) -> str: value = args[name] if not isinstance(value, str): @@ -850,6 +971,8 @@ def send(self, **kwargs: Any) -> SendEmailResult: Raises: EmailError: PostHog did not queue the email, or the client could not tell whether it did. + ``code`` is ``invalid_attachment`` for an attachment the + client can't send, before any request. TypeError, ValueError: An argument has the wrong type or format. Examples: diff --git a/posthog/test/test_emails.py b/posthog/test/test_emails.py index eaf1c0949..0a580feb2 100644 --- a/posthog/test/test_emails.py +++ b/posthog/test/test_emails.py @@ -620,6 +620,8 @@ def handler(request: httpx.Request) -> httpx.Response: HTML = "

Reset it here.

" +INVITE = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" +INVITE_BASE64 = "QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo=" def send_code(emails: Emails, **overrides: Any): @@ -644,6 +646,8 @@ def send_code(emails: Emails, **overrides: Any): "text": "Reset it here.", "from_": "Acme ", "reply_to": "help@example.com", + "cc": "manager@example.com", + "bcc": ["audit@example.com", "archive@example.com"], "capture_engagement": False, "distinct_id": "user_123", }, @@ -652,6 +656,8 @@ def send_code(emails: Emails, **overrides: Any): "text": "Reset it here.", "from": "Acme ", "reply_to": ["help@example.com"], + "cc": ["manager@example.com"], + "bcc": ["audit@example.com", "archive@example.com"], "capture_engagement": False, "distinct_id": "user_123", }, @@ -662,8 +668,8 @@ def send_code(emails: Emails, **overrides: Any): {"html": HTML, "reply_to": ["a@example.com", "b@example.com"]}, ), ( - "empty_reply_to_left_out", - {"html": HTML, "reply_to": []}, + "empty_lists_left_out", + {"html": HTML, "reply_to": [], "cc": [], "bcc": [], "attachments": []}, {"html": HTML}, ), ( @@ -673,6 +679,9 @@ def send_code(emails: Emails, **overrides: Any): "text": None, "from_": None, "reply_to": None, + "cc": None, + "bcc": None, + "attachments": None, "capture_engagement": None, "distinct_id": None, }, @@ -717,6 +726,9 @@ def handler(request: httpx.Request) -> httpx.Response: text="Reset it here.", from_="Acme ", reply_to="help@example.com", + cc="manager@example.com", + bcc=["audit@example.com"], + attachments=[{"filename": "invite.ics", "content": INVITE}], capture_engagement=False, distinct_id="user_123", idempotency_key="reset:user_123", @@ -737,6 +749,9 @@ def handler(request: httpx.Request) -> httpx.Response: "text": "Reset it here.", "from": "Acme ", "reply_to": ["help@example.com"], + "cc": ["manager@example.com"], + "bcc": ["audit@example.com"], + "attachments": [{"filename": "invite.ics", "content": INVITE_BASE64}], "capture_engagement": False, "distinct_id": "user_123", } @@ -766,12 +781,13 @@ def handler(request: httpx.Request) -> httpx.Response: ("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), + ("blank_cc", {"html": HTML, "cc": " "}, ValueError), + ("bcc_not_text", {"html": HTML, "bcc": [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", { @@ -782,6 +798,36 @@ def handler(request: httpx.Request) -> httpx.Response: }, TypeError, ), + ( + "cc_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "cc": "a@example.com", + }, + TypeError, + ), + ( + "bcc_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "bcc": ["a@example.com"], + }, + TypeError, + ), + ( + "attachments_on_template_form", + { + "template_id": TEMPLATE_ID, + "template_key": None, + "subject": None, + "attachments": [{"filename": "a.txt", "content": b"hi"}], + }, + TypeError, + ), ] ) def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error): @@ -799,3 +845,166 @@ def test_rejects_a_code_send_that_mixes_or_misses_fields(_name, overrides, error ) assert adapter.requests == [] + + +def test_a_refused_send_names_the_field_from_attr(): + refused = { + "type": "validation_error", + "code": "invalid_input", + "detail": "PostHog doesn't take this field, so it doesn't send the email without it.", + "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_input", + "cc", + ) + assert len(adapter.requests) == 1 + + +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": INVITE_BASE64, + "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": INVITE_BASE64} + ] + + +SECRET_CONTENT = b"secret-file-content" + + +@parameterized.expand( + [ + ( + "one_file_not_a_list", + {"filename": "a.txt", "content": SECRET_CONTENT}, + "attachments", + ), + ("bytes_not_a_list", SECRET_CONTENT, "attachments"), + ("file_not_a_mapping", [SECRET_CONTENT], "attachments__0"), + ( + "list_of_ints", + [{"filename": "a.txt", "content": list(SECRET_CONTENT)}], + "attachments__0__content", + ), + ("no_content", [{"filename": "a.txt"}], "attachments__0__content"), + ( + "text_not_base64", + [{"filename": "a.txt", "content": "secret-file-content"}], + "attachments__0__content", + ), + ( + "non_ascii_text", + [{"filename": "a.txt", "content": "secret-file-content é"}], + "attachments__0__content", + ), + ("no_filename", [{"content": SECRET_CONTENT}], "attachments__0__filename"), + ( + "empty_filename", + [{"filename": "", "content": SECRET_CONTENT}], + "attachments__0__filename", + ), + ( + "path", + [{"filename": "a.txt", "content": SECRET_CONTENT, "path": "/tmp/a.txt"}], + "attachments__0__path", + ), + ( + "unknown_field", + [ + { + "filename": "a.txt", + "content": SECRET_CONTENT, + "contentType": "text/plain", + } + ], + "attachments__0__contentType", + ), + ( + "none_key_before_an_unknown_field", + [ + { + None: "ignored", + "filename": "a.txt", + "content": SECRET_CONTENT, + "contentType": "text/plain", + } + ], + "attachments__0__None", + ), + ( + "content_type_not_text", + [ + {"filename": "a.txt", "content": "aGk="}, + {"filename": "b.txt", "content": SECRET_CONTENT, "content_type": 1}, + ], + "attachments__1__content_type", + ), + ] +) +def test_rejects_an_attachment_it_cannot_send_without_repeating_its_content( + _name, attachments, field +): + emails, adapter = emails_with([]) + + with pytest.raises(EmailError) as raised: + send_code(emails, html=HTML, attachments=attachments) + + error = raised.value + assert (error.name, error.code, error.field) == ( + "validation_error", + "invalid_attachment", + field, + ) + assert "secret-file-content" not in str(error) + assert "115, 101" not in str(error) + assert adapter.requests == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index bfffd5d64..8698bb0b2 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -888,13 +888,20 @@ 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.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.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 @@ -1344,6 +1351,7 @@ class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_erro 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, 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.EmailWarning(code: str, message: str, fix: Optional[str] = None)