Skip to content

feat: send email content from code with posthog.emails.send - #1101

Draft
Silthus wants to merge 5 commits into
PostHog:posthog/transactional-emailsfrom
Silthus:feat/emails-code-form
Draft

Silthus wants to merge 5 commits into
PostHog:posthog/transactional-emailsfrom
Silthus:feat/emails-code-form

Conversation

@Silthus

@Silthus Silthus commented Oct 22, 2026 •

Copy link
Copy Markdown

💡 Motivation and Context

A Python backend that renders its own email, or keeps its email templates in its code, can't send through PostHog today. emails.send takes only a template made in PostHog, by its ID.

This PR adds the code form of the shared SDK contract in PostHog/posthog#120921 to emails.send, beside the template form of #1093:

# Rendered content: sent as is, never stored as a template
posthog.emails.send(
    to="[email protected]",
    distinct_id="user_123",
    template_key="password-reset",
    subject="Reset your password",
    html=rendered_html,
    from_="Acme <[email protected]>",
)

# Local template: the code is the source of truth, PostHog fills in the variables
posthog.emails.send(
    to="[email protected]",
    distinct_id="user_123",
    template_key="welcome",
    subject="Welcome, {{ variables.name }}",
    html=welcome_html,
    template_data={"name": "Jane"},
)

Note

Stacked on #1093 (posthog/transactional-emails). It retargets to the default branch once #1093 merges.
The server side is the send-api stack in PostHog/posthog: PostHog/posthog#118924, PostHog/posthog#121083, PostHog/posthog#121084 and PostHog/posthog#121272. None of them is merged, so this PR builds against the contract at the HTTP seam.

Impact

Pays into Grow Workflows through email sends from code: the spec's Code-sending orgs metric. It is not measured yet: send attribution (PostHog/posthog#120628) has to count posthog-python/ as an SDK. In scope: Messaging, email. Expected move: small, up. Python callers get the same code form as posthog-node. Run cost: n/a.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door until a release; a release makes the keyword names (template_key, from_, reply_to, capture_engagement, cc, bcc, headers, tags, attachments) and the EmailTag and EmailAttachment shapes a public contract (merge-risk rubric)

  • send now takes **kwargs typed by two TypedDict overloads. The template form keeps its arguments and request, but the dispatch in _prepare now runs for every send.
  • A local template sends a real email after an automatic upload. A wrong 409 branch could upload or send twice. Tests pin one upload at most per send and the same idempotency key on the second send.
  • Limits: nothing releases until feat: send transactional email from posthog templates #1093 merges and this PR merges after it. Every send path has a focused test on a faked transport.

Changes

  • Two forms, one send. TemplateEmailArgs and CodeEmailArgs type the keyword arguments with Unpack, one @overload each, on Emails and AsyncEmails. mypy and pyright reject a send that mixes the forms or misses subject. At runtime, a mixed or unknown argument raises TypeError before any request.
  • Rendered content (no template_data): POST /messaging/send/email/ with template_key, subject, html, text. It never uploads.
  • Local template (with template_data): the client hashes subject, html and text with the server's content_hash v1. It sends template: {key, hash} plus message_data.
  • Upload on 409 template_outdated. The client uploads by key with PUT /messaging_templates/by_key/{key}/, if_not_superseded and if_code_managed. Then it sends once more with the same body and Idempotency-Key. The upload carries no Idempotency-Key and no link to the code. A refused upload (409 template_edited_in_posthog) raises and sends nothing.
  • Sender fields: from_ (since from is a Python keyword) becomes from. A reply_to string becomes a one-item list. capture_engagement is left out when absent, so the server default applies.
  • Copies, headers, tags and attachments (code form only, feat(messaging): send cc, bcc, headers and tags with a code send posthog#121851 and feat(messaging): send attachments with a code send posthog#121852): cc and bcc take one address or a list and are always sent as a list. An empty list is sent as is, like an empty reply_to. headers is a dict[str, str]. tags is a list of EmailTag ({name, value}). attachments is a list of EmailAttachment ({filename, content, content_type?, content_id?}). Each is left out when absent, and none reaches the template upload.
  • Attachment content: bytes are Base64-encoded once, before the retry loop, so a retry resends the same body. A str is sent as is. A list of byte values (Resend's Node shape) raises TypeError that names bytes(content), and no error repeats the content. The SDK checks types only: the server holds the limits.
  • One error-name change in feat: send transactional email from posthog templates #1093's mapping: a 409 maps to concurrent_idempotent_requests only for the in-flight code, and to validation_error otherwise, as the contract's error table says. Before, every 409 other than template_outdated was concurrent_idempotent_requests.
  • The send loop moved into _request on each class, so the send and the upload share retries. feat: send transactional email from posthog templates #1093's recipient checks moved into _recipient, so both forms share them. Neither move changes the template form's behavior.
  • Mechanical: the public API snapshot, a changeset, the docstrings, and the email sends in the strict types check (its invalid-input file is now invalid_types.py).

Parity against posthog-node and the server

posthog-node's code form (slice 10 of the same map) is not built yet, so this table compares with the contract, posthog-node's draft PostHog/posthog-js#5312, and the server route in PostHog/posthog#121272.

Request part posthog-python (this PR) Contract PostHog/posthog#120921 Server PostHog/posthog#121272 / posthog-js#5312
Rendered content body to, template_key, subject, html, text, from, reply_to[], capture_engagement, identifiers.distinct_id the same names SendEmailSerializer takes the same fields
Local template body to, template: {key, hash}, message_data, sender fields, identifiers the same the same
Copies, headers, tags cc[], bcc[], headers{}, tags[{name, value}] on both code-form bodies the same (Changes, 2026-10-22) the serializers in PostHog/posthog#121851
Attachments attachments[{filename, content, content_type?, content_id?}], content in Base64 the same; Node also takes Buffer the serializer in PostHog/posthog#121852
Content hash v1: + SHA-256 of the length-prefixed subject, preheader, html, text, line endings normalized PostHog/posthog#118924's content_hash v1 matches the server's test vectors, which PostHog/posthog-js#5312 also asserts
Upload PUT by_key/{key}/ with name, subject, html, text, if_not_superseded, if_code_managed upload by key with if_code_managed: true PostHog/posthog-js#5312 sends name: subject and a source; this PR sends name: template_key and no source
Retry after outdated once, same Idempotency-Key and body the same PostHog/posthog-js#5312 also uploads on 404 template_not_found; the contract and PostHog/posthog#121272 answer 409 template_outdated for an unknown key, so this PR does not
JSON encoding compact separators, UTF-8 not specified JSON.stringify output, so the bytes match
User-Agent posthog-python/<version> the same posthog-node/<version>

💚 How did you test it?

Test seams: Emails.send and AsyncEmails.send on a real client with the HTTP transport faked, as #1093's tests do (FakeAdapter, httpx.MockTransport). The tests assert the requests the client makes and what it returns or raises.

  • test_rendered_content_is_posted_as_is: one case per code-form mapping row (html only, text only, every field, several reply_to, empty address lists).
  • test_a_local_template_is_sent_by_key_and_content_hash: the hash equals the server's content_hash v1 test vectors, including the Windows line-ending vector.
  • test_an_outdated_local_template_is_uploaded_then_sent_again_with_the_same_key and its async twin: send, upload, send, with one idempotency key and the same body.
  • test_a_local_template_uploads_at_most_once_per_send: a refused upload, and a second template_outdated, each raise validation_error with no further request.
  • test_rendered_content_is_never_uploaded.
  • test_rendered_content_carries_copies_headers_and_tags and test_a_local_template_sends_copies_headers_and_tags_but_never_uploads_them: the new fields reach both sends, and the upload body stays the same.
  • test_attachments_are_sent_base64_encoded_once_across_retries: a 503 then a 202, with the same Base64 body on both tries.
  • test_rejects_an_attachment_that_is_not_a_file: a list of byte values, a missing filename, an unknown field. No request is made, and the message does not repeat the content.
  • test_rejects_a_code_send_that_mixes_or_misses_fields: mixed forms, no form, no content, no subject, a literal from keyword, bad keys, a blank cc address, cc on the template form, template_data that is not a mapping.
  • Type seam: .github/scripts/check_strict_types.sh now type-checks both forms on Emails and AsyncEmails under pyright strict. It also asserts errors on five wrong sends: both forms at once, a code send without subject, an async mix, cc on the template form, and an attachment content that is a list of ints. mypy reports the same "no overload matches" errors.
  • Local gate: pytest (4906 passed), ruff format --check, ruff check, mypy | mypy-baseline filter, the strict types script, the public API snapshot check, and python -W error -c "import posthog".

Proofs owed:

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Decisions

Question Answer Who Where
Base for the code form Stack on #1093 now, retarget once it merges 👤 Human map conductor
Python name for from from_ 🤖 Agent this PR
Upload name the template_key, since the code form has no name and a Liquid subject reads badly in the library 🤖 Agent this PR
Upload on 404 template_not_found no, only on 409 template_outdated, per the contract 🤖 Agent this PR
Add cc, bcc, headers, tags, attachments here yes, as new commits on this PR 👤 Human map conductor
Attachment content as a list of byte values refuse with TypeError, since the contract takes only bytes or a Base64 string 🤖 Agent this PR

Involvement: 0 grilling rounds, 0 prototypes, 1 design pass before the PR opened (2 findings, both fixed), 2 cross-model review rounds (round 1: 1 finding, open below; round 2: 2 findings, 1 fixed, 1 recorded as the cross-version gap), proof: live run and cross-version recorded as gaps.

Open questions

🤖 Generated with Claude Code

Silthus and others added 2 commits October 22, 2026 06:28
The code form of emails.send takes the new optional fields of the
contract. cc and bcc accept one address or a list and are always sent as
a list. Attachment bytes are Base64-encoded once, before the retry loop.
A list of byte values is refused with a TypeError that names bytes().
None of the fields reach the template upload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The server accepts an empty address list, and the SDK checks types only,
so a computed empty list of copies no longer raises before the send.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant