Skip to content

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

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

Silthus wants to merge 8 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, rebased onto its head 275a64d on 2026-10-23). 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#121272, PostHog/posthog#121867, PostHog/posthog#121851 and PostHog/posthog#121852. 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. A shadow diff shows the template form's requests, results and errors unchanged against feat: send transactional email from posthog templates #1093's head.

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.
  • Content checks: subject must be a nonempty string. html and text must be strings, and at least one must have content, so a local template with HTML and an empty text file sends. template_key must be letters, digits, - and _ from start to end, so a key with a trailing newline raises ValueError before any request. Across the code form, a value of the wrong type raises TypeError and an empty one raises ValueError.
  • 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, bytearray and any memoryview (such as BytesIO.getbuffer()) 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.
  • Contract entries of 2026-10-22 and 2026-10-23. A server that doesn't take a field refuses the send with 400 invalid_request, and EmailError.field names the field. A test pins this, and no request repeats. 400 transactional_email_paused and 422 idempotency_key_reused map like every 400 and 422, with no retry. The CodeEmailArgs docstring now says that HTML with no text goes out without a plain-text part, that template and code sends share one set of idempotency keys, and that a retry replays after its workflow is paused.
  • On feat: send transactional email from posthog templates #1093's new head: the code-form send, its upload and its second send run inside feat: send transactional email from posthog templates #1093's shutdown admission (_start_send), so shutdown() waits for all three requests, and a send after shutdown raises client_closed.
  • 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. An empty text sends and hashes like no text.
  • 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_attachment_content_from_any_bytes_buffer_is_base64_encoded: bytearray, memoryview and a strided memoryview give the same Base64 as bytes.
  • 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_a_server_that_does_not_take_a_field_refuses_the_send_and_names_it: a 400 invalid_request with attr: cc raises validation_error with field == "cc", after one request. Dropping field from the error fails it.
  • test_rejects_a_code_send_that_mixes_or_misses_fields: mixed forms, no form, no content, no subject, a subject or from_ that is not text, a literal from keyword, bad keys (a space, a trailing newline, too long), empty html and text, a blank cc address (ValueError), a cc that is not text (TypeError), 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 (4919 passed, on a6bc0ac), 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
SDK change for the unknown-field refusal none: EmailError.field already carries attr, so a test pins it and the docstring names it 🤖 Agent this PR
Attachment content as a list of byte values refuse with TypeError, since the contract takes only bytes or a Base64 string 🤖 Agent this PR
An empty html or text beside a body with content take it, since the contract says the SDK checks types only and the upload route takes it; a rendered send leaves the refusal to the server 🤖 Agent decision comment
Merge risk after a reviewer graded it high keep medium: nothing is released, the code form is opt-in, a repeated send reuses one idempotency key, and the shadow diff shows the template form unchanged 🤖 Agent decision comment
Send and upload parsers keep the parser that _request takes, instead of moving error mapping into _request, so #1093's loop changes the least 🤖 Agent decision comment

Involvement: 0 grilling rounds, 0 prototypes, 1 design pass before the PR opened (2 findings, both fixed), 4 cross-model review rounds and 2 final reads (round 1: 1 finding, since fixed on the server; round 2: 2 findings, 1 fixed, 1 recorded as the cross-version gap; final read 1: clean; round 3 after the rebase, 8 lanes including 2 team perspectives: 10 findings, 8 fixed, 1 answered with proof, 1 rejected; round 4: 3 findings, 2 fixed, 1 rejected as #1093's code; final read 2: clean), no review bots on this repo, proof: shadow diff captured; live run, cross-version and prod shape recorded as gaps.

Open questions

  • Splitting this PR: it changes 560-some production lines in posthog/emails.py, over the size where a split usually pays. Recommendation: keep one PR, since it is already open and in review, and splitting means new upstream PRs.
  • Contract differences in feat: send transactional email from posthog templates #1093, left as they are: the SDK error for a custom host is app_host_required, where the contract says missing_app_host. A Retry-After over 30 s raises at once, where the contract waits up to 30 s. The result has an extra replayed field. A disabled client returns id=None instead of a client_disabled error. Local checks raise TypeError and ValueError, not validation_error. There is no per-send max_retries, no migrated_from option or X-PostHog-Migrated-From header, and the template form has no capture_engagement. The client option is email_request_max_retries, not max_retries. client_closed is not in the contract's SDK codes. Its idempotency-key check accepts a trailing newline (this PR fixes the same pattern for template_key). The template form stays on /messaging_templates/{id}/send/ until feat(messaging): upload, send and lock code-managed email templates posthog#118924 lands.
  • Template-form body encoding: feat: send transactional email from posthog templates #1093 encodes with json.dumps defaults (spaces, \u escapes). The code form uses compact UTF-8, so it matches posthog-node byte for byte. Recommendation: switch the template form too when it moves to /messaging/send/email/.
  • Local check errors: the contract gives local checks the name validation_error and codes such as invalid_form. This PR raises TypeError or ValueError before any request, as feat: send transactional email from posthog templates #1093's checks do. Recommendation: decide this for both forms at once, in feat: send transactional email from posthog templates #1093's error model.
  • Contract wording: the contract should name from_ as the Python spelling, and say whether the upload name is the key.

🤖 Generated with Claude Code

Silthus and others added 6 commits October 23, 2026 13:48
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]>
A PostHog that doesn't take a field refuses the send with invalid_request
and names it in EmailError.field, which a test now pins. HTML with no text
goes out without a plain-text part. Template and code sends share one set
of idempotency keys, and a retry replays after its workflow is paused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Base64-encode attachment content from bytearray and memoryview too
- Refuse a template_key with a trailing newline before the send
- Take an empty html or text when the other body has content
- Raise TypeError for a cc, bcc or reply_to that is not text
- Inject the async test transport through http_client

Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Base64-encode a non-contiguous memoryview through bytes()
- Raise TypeError for a subject or from_ that is not a string
- Name bytearray and memoryview in the changeset

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

Silthus commented Oct 24, 2026

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions from the round-3 and round-4 reviews of the rebased code form:

  • 🤖 An empty html or text beside a body with content Take it. The contract says the SDK checks types only, and the upload route takes a blank alternate body. A rendered send leaves the refusal to the server.
  • 🤖 Merge risk after a reviewer graded it high Keep medium. Nothing is released, the code form is opt-in, a repeated send reuses one idempotency key, and a shadow diff shows the template form unchanged against feat: send transactional email from posthog templates #1093's head.
  • 🤖 Send and upload parsers Keep the parser that _request takes, instead of moving the error mapping into _request, so feat: send transactional email from posthog templates #1093's retry loop changes the least.

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