Skip to content

feat(node): upload local email templates by hash on send - #7

Closed
Silthus wants to merge 6 commits into
emails-v2/01-send-inline-htmlfrom
emails-v2/03-local-template
Closed

Silthus wants to merge 6 commits into
emails-v2/01-send-inline-htmlfrom
emails-v2/03-local-template

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

A team that keeps its email templates in code wants PostHog to fill them with per-send data, without uploading by hand on every deploy. This PR adds the local template: a code send with templateData sends a content hash, and the SDK uploads the template by key only when PostHog doesn't hold that content.

Stacks on #5. Needs the server's template upload by key and its template: { key, hash } arm. Part of map Silthus/posthog#461.

Impact

Same as the stack: Grow Workflows, billed workflows_emails from API sends.

Merge Danger

Caution

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

  • Writes templates in a customer's project (PUT messaging_templates/by_key/{key}/) from code. The SDK sends if_code_managed and if_not_superseded, but the server branch it targets ignores if_code_managed and adopts the template instead (see Server prerequisites).
  • The content hash v1 becomes a format the server and posthog-python must keep computing the same way.

Changes

Kind Files Lines (against #5)
Production 4 +182 −39
Tests 3 +188 −9
References and changesets 2 +12 −2
  • Local template (code form with templateData): the SDK hashes subject, html and text with the content hash v1 (emails/content-hash.ts) and sends template: { key, hash } with message_data, identifiers.distinct_id, from, reply_to and capture_engagement.
  • Upload on demand. On 409 template_outdated it uploads by key (PUT messaging_templates/by_key/{key}/ with if_code_managed: true and if_not_superseded: true) and sends once more with the same idempotency key. It uploads at most once per send: a refused upload or a second template_outdated returns as the error.
  • A refused upload is a validation_error. 409 template_edited_in_posthog means people edit the template in PostHog, so waiting won't fix it.
  • A send copies its options and checks the client before it hashes. Changing the options after send() changes nothing, and shutdown() drains a send that started before it.
  • A local template needs html and a subject. PostHog stores it, and a stored template needs both. A local template with no html, an empty html or an empty subject returns invalid_form on that field before any request, and CodeEmailForm needs html with templateData.
  • Superseded content isn't uploaded again. When PostHog holds newer content for the key, the send goes out with that content and a template_superseded warning.
  • Rendered content without templateData never uploads.

Server prerequisites

  • if_code_managed: contract Transactional email SDK contract for posthog-python and posthog-node PostHog/posthog#120921 says the server refuses an upload over a template people edit in PostHog, with 409 template_edited_in_posthog. The server branch this targets (email-v2/04c-template-lock-adopt) ignores the flag: it locks code-managed templates and adopts on upload. The SDK sends the flag and maps the contract's answer, so it needs no change either way. The server must settle which one ships.
  • A new template needs a verified default sender. The upload of a new key answers 400 sender_unavailable when the project has no verified default sender, even when the send passes from. The contract gives the upload no sender field, so the SDK returns that answer as a validation_error and sends nothing.

Evidence

  • Test seams: posthog.emails.send() on a real client with an injected fetch; emailContentHash through its public function, against the server's content_hash v1 test vectors.
  • What the tests pin: a local template without html or a subject refused before any request, and a text-only local template refused by the types; one request when PostHog holds the content; upload then resend with one idempotency key, the upload's headers and body; at most one upload per send; a refused upload named validation_error; no upload for rendered content; options changed after send() (to, templateData, replyTo) change nothing; a send started before shutdown() still sends.
  • Lane gate on 1c241bb6c: node vitest and edge (1512 tests), core vitest, check-types, oxlint, oxfmt, build, references committed, pnpm check:public-api clean.
  • Shadow diff, feat(node): send email content from code with emails.send #5's head against 3cc39e036: 22 invented inputs through posthog.emails.send() with scripted replies, recording every request and result. 17 are identical, including every template-form case and every rendered code send. The 5 that differ are all local-template cases this PR adds: held, uploaded, refused upload, text only, and empty subject. 1c241bb6c only drops two comments.
  • CI: call-flags-project / add-to-project-board fails on every fork PR (recorded as a ci gap); the other checks run on the fork.
  • Owed proofs:
    • Live run: gap. The earlier run below used the old base. Lane dj3 runs the live run. A local template needs the server to answer 409 template_outdated for a key it doesn't know, and a new key needs a verified default sender in the project.
    • Cross-version: hash v1 matches the server's test vectors in email-content-hash.spec.ts; old-server runs are a gap, accepted.
    • Production shape: gap until read-only production access is available.
Earlier live run, at da59e276 before the restack (2026-10-26)

Against Django at Silthus/posthog#578 c4c5ff05d2f and cdp-api at Silthus/posthog#580 1a43bdda327, on a local stack with a dev mail catcher:

Case Requests Delivered
a new key send 409 template_outdated, upload 201, send 202, one idempotency key on all three one email, variables filled
a key uploaded before with the same content send 202 the same
a key uploaded before with older content send 409, upload 200, send 202, one key the same, with the new content

Decisions

Question Answer Who Where
Where distinct_id goes Nested identifiers.distinct_id on a local template, like the template form; the flat field is only for the template_key code form 🤖 Agent pending approval
What a local template needs html and a subject that isn't empty, checked before any request; a text-only local template is refused, since PostHog can't store it 🤖 Agent pending approval
The name of a refused template upload 409 template_edited_in_posthog is validation_error, as contract #120921 says; other 409s stay concurrent_idempotent_requests 🤖 Agent pending approval

No open questions.

Involvement: carries the PostHog#5371 review rounds. This rebuild: one full-scope GPT-6.1 Sol read at 660f6a0f1 (2 findings, both fixed in bf8cfa2b3); a QA swarm at bf8cfa2b3 with a full two-family panel (GPT-6.1 Sol on six lenses, Claude Opus 5.5 fresh eyes: 6 fixed, 2 server follow-ups) and a narrow round on the fixes (1 fix, comments dropped); shadow diff captured; live run owed to lane dj3.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • All commits are signed (see Commit signing)
  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Coherence

📄 0 spec file errors · ⛔ 0 broken rules · 🚪 2 unchecked inputs · 🐛 0 bugs that came back

What is Coherence?

Coherence compares the code with its spec files (*.spec.md). A spec file lists the rules for one part of the code.

This check read 44 parts of the code and 7 rules.

Note

These findings do not make this check fail.

None of these findings are in files that this pull request changes. You do not need to fix them in this pull request.

🚪 Unchecked inputs (2)

  • public api in packages/web/Web.spec.md. The input comes from: host-app.
  • webpack plugin in packages/webpack-plugin/Webpack-plugin.spec.md. The input comes from: build.
What this means and how to fix it

Input from outside this code comes in at these places. The spec does not say what checks that input. 10 more places were already like this when Coherence was added. This list does not show them.

To close one of these, find the place by its name in the ## entrances section of its spec file. Add one line under it:

  • If all of the input goes through one function that a rule in the spec protects, add guard: <function>.
  • If a rule in the spec already checks input from the same source, add the place's name to the entrances: line of that rule.
  • If the input needs no check, add control: none - <reason>.

To get the line to add, run npx --no -- coherence scaffold control "<name>".

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Node SDK acceptance v2

Commit: 1c241bb
Workflow run and artifacts

Capture v0

✅ 83 passed / ❌ 4 non-passing / ⚠️ 954 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1

✅ 148 passed / ❌ 4 non-passing / ⚠️ 889 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

@Silthus
Silthus force-pushed the emails-v2/02-template-form-route branch from 86b2ad7 to db5ac2d Compare October 25, 2026 10:44
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch 2 times, most recently from 95be057 to da59e27 Compare October 25, 2026 19:11
@Silthus
Silthus force-pushed the emails-v2/02-template-form-route branch from db5ac2d to 8384b8c Compare October 25, 2026 19:11
A send with templateKey and templateData names content from code that
PostHog fills in. The SDK sends the template's key and content hash, and
uploads the template once when PostHog answers template_outdated, then
sends again with the same idempotency key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus changed the base branch from emails-v2/02-template-form-route to emails-v2/01-send-inline-html October 31, 2026 08:31
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch from da59e27 to 4e4bcb2 Compare October 31, 2026 08:32
Silthus and others added 2 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>
A send now validates, copies its options and checks the client before
its first await, so shutdown drains a send that started before it and
a caller who changes replyTo afterwards doesn't change the email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 3 commits November 1, 2026 16:42
PostHog stores a local template on upload, and its upload route needs a
subject and an HTML body. A text-only local template got 409
template_outdated, then a 400 on the upload, on every send. The SDK now
refuses it before any request, and the types need html with templateData.

The local-template mutation test no longer depends on which of two
content hashes finishes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upload body is built in upload() from the local template the send
already holds, so the TemplateUpload shape is gone. The content hash
builds its input as one string and encodes it once, which drops the byte
concatenation helper. The hash test vectors still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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#5513.

@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