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)