Skip to content

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

Closed
Silthus wants to merge 5 commits into
mainfrom
feat/emails-code-form
Closed

Silthus wants to merge 5 commits into
mainfrom
feat/emails-code-form

Conversation

@Silthus

@Silthus Silthus commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

💡 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 to emails.send, beside the template form that PostHog#1093 shipped in 7.81.0. The SDK contract is PostHog/sdk-specs openspec/specs/emails/spec.md, plus the draft change add-emails-code-form for the code form.

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

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

Part of map Silthus/posthog#461. Rebased onto main after PostHog#1093 merged, so this diff holds only the code form. It absorbs what the spec allows from the closed #3.

Impact

Pays into Grow Workflows through email sends from code: the spec's Code-sending orgs metric, not measured yet (send attribution 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

Caution

🔴 Merge risk: high · one-way door once released (merge-risk rubric)

  • A release makes the keyword names (template_key, from_, reply_to, capture_engagement, cc, bcc, headers, tags, attachments, max_retries), the migrated_from client option and the EmailTag and EmailAttachment shapes a public contract.
  • 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.
  • The code form needs the server's code arm of send/email/ with the flat body, which is not on PostHog master yet. Release this after it.

Changes

Kind Files Lines (against main)
Production 4 +623 −85
Tests 1 +509 −1
Snapshot and changeset 2 +42 −6
  • Two forms, one send. TemplateEmailArgs and CodeEmailArgs type the keyword arguments with Unpack, one @overload each, on Emails and AsyncEmails. A send with both forms, neither form, or a field of the other form raises TypeError before any request.
  • Rendered content (no template_data): a flat body to POST /messaging/send/email/: template_key, subject, html, text, from, reply_to, capture_engagement, to, distinct_id. It never uploads.
  • Local template (with template_data): the client hashes subject, html and text with the server's content_hash v1 and sends template: {key, hash}, message_data and identifiers.distinct_id. On 409 template_outdated it uploads by key (PUT /messaging_templates/by_key/{key}/, if_not_superseded, if_code_managed) and sends once more with the same body and Idempotency-Key. A refused upload (409 template_edited_in_posthog) raises a validation_error and sends nothing. A local template without html raises ValueError before any request, because PostHog stores it and a stored template needs HTML.
  • None counts as left out for an optional code-form field (html, text, from_, reply_to, cc, bcc, headers, tags, attachments, capture_engagement, template_data), as it already did for distinct_id. A wrapper can pass its own optional values through.
  • Engagement capture belongs to the code form. capture_engagement is always sent there, True unless the call passes False. The template form refuses it with TypeError.
  • Copies, headers, tags and attachments on the code form only: cc and bcc as lists, headers, tags, and attachments with bytes-like content Base64-encoded once, before the retry loop.
  • From the closed feat: align email sends with the shared SDK contract #3: migrated_from on the client, sent as X-PostHog-Migrated-From on every email request and refused at construction unless it is visible ASCII; and max_retries on a code send, as the template form has it.
  • Kept as main has them: a Retry-After over 30 s raises at once, replayed and warnings stay on the result, and argument checks raise TypeError or ValueError.

💚 How did you test it?

Test seams: Emails.send and AsyncEmails.send on a real client with the HTTP transport faked, as PostHog#1093's tests do (FakeAdapter, httpx.MockTransport). The tests assert the requests the client makes and what it returns or raises. The type seam is .github/scripts/check_strict_types.sh under pyright strict.

  • What the new tests pin: each code-form body with a flat distinct_id and capture_engagement; the hash against the server's v1 test vectors; upload then resend with one key, sync and async; at most one upload per send, with a refused upload named validation_error; no upload for rendered content; copies, headers, tags and attachments on both sends of a local template and never on the upload; Base64 once across retries; each TypeError and ValueError refusal, including a local template without html; None for every optional code-form field sent as left out; capture_engagement refused on the template form; migrated_from on send and get, and refused when blank or not text; max_retries on a code send.
  • Mutation check: dropping the migrated_from header, the capture default, the template-form capture refusal or the code send's max_retries each fails a test.
  • Lane gate on e5fdedf: ruff format --check, ruff check, mypy | mypy-baseline filter, the strict types script, the public API snapshot, and the full pytest suite (5936 passed).
  • Shadow diff, main (dca2207) against 1fef822: 22 invented inputs through Emails.send with the transport faked, recording every request and result. The 12 template-form cases are identical. The 10 that differ are all code-form sends, which main refuses and this PR sends as described above. e5fdedf only drops two comments.
  • CI: GitHub Actions don't run on this fork, so the PR shows no checks (recorded as a ci gap); the lane gate above runs the same steps.

Proofs owed:

  • Live run: gap. Lane dj3 runs it in this round with this head against the restacked server code send. A new local template also needs a verified default sender in the project: the server's upload answers 400 sender_unavailable without one, even when the send passes from_.
  • Cross-version (new SDK, older server): gap. No deployed server has the code arm of send/email/. An older server answers 400, which raises validation_error.
  • Prod shape: gap until read-only production access is available.

📝 Checklist

  • All commits are signed (see Commit signing)
  • 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 PostHog#1093, retarget once it merges (done) 👤 Human map conductor
Add cc, bcc, headers, tags, attachments here yes, in this PR 👤 Human map conductor
Python name for from from_ 🤖 Agent this PR
Upload name the template_key 🤖 Agent this PR
Where distinct_id goes on a code send Flat at the top level, like PostHog/posthog#126287; the template form and the local template keep identifiers.distinct_id 🤖 Agent pending approval
Argument errors Native TypeError and ValueError, as 7.81.0 has them; the typed validation error of #3 is not ported 🤖 Agent pending approval
What to take from #3 migrated_from, max_retries on a code send, and the capture default on the code form only; not its Retry-After clamp or the removal of replayed, which the spec contradicts 🤖 Agent pending approval
None for an optional code-form field Counts as left out, as it does for distinct_id and in posthog-node; the alternative was to document that the code form never takes None 🤖 Agent pending approval
What a local template needs html, checked before any request, since PostHog can't store a template without it 🤖 Agent pending approval
The name of a refused template upload 409 template_edited_in_posthog is validation_error, as contract #120921 says 🤖 Agent pending approval

No open questions.

Follow-ups:

  • The diff is above the ~400 production lines a single review reads well. It stays one PR, because the code form, the local template and the code fields share one send and its checks; split it if a reviewer asks.
  • An explicit empty reply_to, cc or bcc list is sent as [], as posthog-node does. An older server branch refused reply_to: [] with validation_error on reply_to; the live run checks the restacked server.

Involvement: carries the earlier #2 review rounds and design pass. This rebuild: one full-scope GPT-6.1 Sol read at b80b8ab80 (2 findings: 1 fixed in 3f2428df8, 1 rejected because the flat distinct_id is the round's decision); a QA swarm at 3f2428df8 with a full two-family panel (GPT-6.1 Sol on six lenses, Claude Opus 5.5 fresh eyes: 3 fixed) and a narrow round on the fixes (comments dropped); shadow diff captured; live run owed to lane dj3.

🤖 Generated with Claude Code

A send with template_key carries subject and html, text or both, flat at
the top of the body with distinct_id. With template_data, the content is a
local template sent by key and content hash, uploaded when PostHog answers
template_outdated. A code send also takes from_, reply_to, cc, bcc,
headers, tags, attachments and max_retries, and captures engagement unless
it passes capture_engagement=False. A template_id send doesn't take
capture_engagement. migrated_from on the client or on Emails goes out as
X-PostHog-Migrated-From on every email request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from base/posthog-transactional-emails to main October 31, 2026 08:25
@Silthus
Silthus force-pushed the feat/emails-code-form branch from a6bc0ac to b80b8ab Compare October 31, 2026 08:25
Silthus and others added 4 commits October 31, 2026 08:37
A 409 template_edited_in_posthog from a local-template upload is a
permanent refusal, not a concurrent request, so it carries the name
validation_error like template_outdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog stores a local template on upload, and its upload route needs an
HTML body. A text-only local template got 409 template_outdated, then a
400 on the upload, on every send. The client now raises ValueError before
any request, as posthog-node refuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wrapper that forwards its own optional values passed text=None or
template_data=None, and the code form raised TypeError, while
distinct_id=None and the template form's template_data=None already
counted as left out. The code form's optional fields now do too, and
their types say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring names None as a left-out field, and the error names the html rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 2, 2026

Copy link
Copy Markdown
Owner Author

Moved to PostHog#1171.

@Silthus Silthus closed this Nov 2, 2026
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