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
7 changes: 5 additions & 2 deletions .github/scripts/check_strict_types.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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="<p>Reset</p>", from_="Acme <security@example.com>", 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="<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:
Expand Down Expand Up @@ -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="<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>", cc="team@example.com") # 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:
Expand Down
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.
129 changes: 126 additions & 3 deletions posthog/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
from __future__ import annotations

import asyncio
import base64
import binascii
import importlib
import json
import logging
Expand Down Expand Up @@ -127,6 +129,7 @@
__all__ = [
"AsyncEmails",
"CodeEmailArgs",
"EmailAttachment",
"EmailError",
"EmailStatus",
"EmailWarning",
Expand Down Expand Up @@ -196,6 +199,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
``<img src="cid:logo">`` 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]]
Expand Down Expand Up @@ -229,6 +251,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
Expand All @@ -240,6 +266,13 @@ class CodeEmailArgs(_RecipientArgs):
without a plain-text part.
from_: A verified sender, such as ``"Acme <hello@example.com>"``.
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
Expand All @@ -261,15 +294,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",
}
)
Expand Down Expand Up @@ -376,9 +416,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.")
Expand All @@ -397,6 +441,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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Bound attachments before encoding

The documented 10-file/512 KB limits are not checked before this comprehension encodes every attachment and _encode materializes the JSON body; an isolated preparation check accepted an 8 MiB file and allocated roughly 32 MiB beyond the original input. As defense in depth for applications forwarding user uploads, check file count and aggregate size before encoding, including bounds for Base64 strings, since server-side rejection cannot protect the host process from these allocations.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. The server owns the 10-file and 512 KB limits, so it can raise them without a new SDK release. A copy of the limits here would drift. The caller already holds the file in memory, and Base64 adds a third to its size. That cost is the caller's choice, and the server refuses the send before anything goes out.



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):
Expand Down Expand Up @@ -848,6 +969,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:
Expand Down
Loading
Loading