Skip to content

feat(node): send cc, bcc, headers, tags and attachments with a code send - #3

Draft
Silthus wants to merge 4 commits into
feat/node-emails-code-formfrom
feat/node-emails-code-fields
Draft

Silthus wants to merge 4 commits into
feat/node-emails-code-formfrom
feat/node-emails-code-fields

Conversation

@Silthus

@Silthus Silthus commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Moved here from PostHog#5373, closed on 2026-10-25 so this fork stack is the only copy. It opens upstream again after review.

Problem

A Resend call site that sends a copy, a custom header, a tag or a file can't move to posthog.emails.send(): the code form takes only the content, the sender and replyTo.

This PR adds cc, bcc, headers, tags and attachments to the code form, as the shared SDK contract PostHog/posthog#120921 added them on 2026-10-22:

await posthog.emails.send({
  to: 'jane@example.com',
  distinctId: user.id,
  templateKey: 'invoice',
  subject: 'Your invoice',
  html: invoiceHtml,
  cc: 'finance@example.com',
  tags: [{ name: 'category', value: 'billing' }],
  attachments: [{ filename: 'invoice.pdf', content: await readFile('invoice.pdf') }],
})

Follow-up slice of strand Silthus/posthog#536, map Silthus/posthog#461.

Stacked on #5371 (code form), on #5312. #5372 (emails.get) now stacks on this PR. This fork PR targets main, so its diff includes the PRs below it. This layer alone: feat/node-emails-code-form...feat/node-emails-code-fields.

Server dependency, not merged: the fields come from PostHog/posthog#121851 (cc, bcc, headers, tags) and PostHog/posthog#121852 (attachments), stacked on #121272. This PR is built against the contract and those serializers at the HTTP seam.

Impact

Pays into Grow Workflows through billed workflows_emails from API sends, read as orgs that switched from Resend (not measured yet: the send attribution of PostHog/posthog#120628 plus its insight). In scope: Messaging, email. Expected move: small, up: it removes a blocker for Resend call sites that use these fields. Run cost: n/a.

Merge Danger

Caution

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

  • Adds cc, bcc, headers, tags and attachments to the public code form. Once released, the field names and attachment encoding are a contract, and a send delivers a real email, with its copies, to real recipients.
  • The server checks every limit; the SDK checks types and refuses these fields on the template form. Attachment content is never logged. Tests at the HTTP boundary pin the exact body; not yet run against a live server (gap below).

Changes

Kind Files Lines (this layer)
Production 2 +120 −10
Tests 2 +131 −0
References and changeset 2 +102 −2
  • Code form only. The new fields work with rendered content and with a local template. The template form's types refuse them, and at runtime a template send with one of them returns invalid_form on that field instead of sending without it.

  • On the wire. Each field is left out when absent.

    Field Request
    cc, bcc A string becomes a one-item list
    headers, tags As given
    attachments { filename, content, content_type?, content_id? }. A Buffer or Uint8Array becomes Base64; a string goes as is
  • Once per send. The SDK encodes attachments when it builds the body, before the retry loop and before a local template's hash. Retries and the resend after a local-template upload send the same body. The upload never carries these fields.

  • An older server. A server that doesn't take one of these fields yet answers 400 invalid_request on it; the error names it in field, so the caller sees which field to drop.

  • A Resend path (a URL or file path to fetch) returns invalid_attachment, named validation_error, with field: attachments__<n>__path, before any request. So does an attachment without Buffer, Uint8Array or string content, or attachments that is not a list. The types refuse path too.

  • Content stays private. The SDK never logs attachment content, and no error repeats it.

  • Limits stay on the server. The SDK checks types only; the server refuses more than 10 copies, reserved headers, bad tag names, blocked file types and more than 512 KB.

Parity with the contract

Contract row posthog-node (this PR)
cc, bcc: string or list Always a list
headers: map As is
tags: { name, value }[] As is
attachments: { filename, content, content_type?, content_id? } content Base64 from a Buffer or Uint8Array, a string as is
Template form Refused by the types and at runtime with invalid_form
Attachment content Never logged or repeated, encoded once before the retry loop

Evidence

  • Test seams:
    • The emails namespace on a real client, fetch mocked by FakeEmailsApi (emails.spec.ts).
    • The form types, checked by tsc with @ts-expect-error cases (__tests__/types/emails-forms.ts).
  • What the tests pin:
    • The exact body of a rendered send with every new field. It covers a Buffer, a Uint8Array, a Base64 string, and a 40 KB file that crosses the encoder's chunk size.
    • A local template that sends the fields on both sends, with an identical body, and never in its upload.
    • invalid_attachment without a request, for a path, for content of another type, and for an attachments object that is not a list.
    • invalid_form with its field for each new field on the template form, without a request.
    • 400 invalid_request named validation_error, with its field.
    • Types:
      • The template form refuses cc and attachments.
      • An attachment refuses path.
  • Red first: 4 cases and the type tests failed before the change, then green. The review fix's 5 cases failed before it, then green.
  • Local gate on abeb1979 (on the leased compute box): node vitest (1336 passed), the edge build tests (1336 passed), tsc for the build and the type tests, oxlint, oxfmt, build and generate-references; core vitest.
  • Owed proofs:

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

  • 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)

Michael (@Silthus) approved adding the fields to the contract and to posthog-node. Built by Claude Code (Claude Opus 5.5) test-first, as one build lane of the map.

Question Answer Who Where
Which fields the code form adds cc, bcc, headers, tags, attachments, code form only 👤 Human #120921, Changes
Attachment content types Buffer, Uint8Array or a Base64 string, snake_case on the wire 👤 Human #120921, Client interface
Resend's attachment path Refused with invalid_attachment before any request 👤 Human lane brief for this slice
Where this PR sits in the stack Between PostHog#5371 and PostHog#5372, so the stack stays one line 🤖 Agent this PR
Base64 encoder btoa over byte chunks, not Buffer, so the edge build works too 🤖 Agent this PR

Open: none.

Involvement: decisions by Michael through the contract; one review by GPT-6.1 Sol with no findings; a QA swarm over the restacked stack in two rounds (Review lane GPT-6.1 Sol with seven lenses, Fresh-eyes lane Claude Opus 5.5) with its fix in this PR; no live run yet.

🤖 Generated with Claude Code

Silthus and others added 4 commits October 24, 2026 12:40
The code form of emails.send takes the fields the contract added on
2026-10-22. Addresses become lists, attachment bytes become Base64 once
per send, and a Resend attachment path returns invalid_attachment
before any request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PostHog server that doesn't take cc, bcc, headers, tags or attachments yet
answers 400 invalid_request with the field in attr, so the send fails as a
validation_error on that field instead of going out without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e send

A template send with one of these fields returns invalid_form on that
field, instead of sending without it.

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

Copy link
Copy Markdown

Node SDK compliance v2

Commit: abeb197
Workflow run and artifacts

Capture v0 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 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:

Harness exit: 1; report validation exit: 1.

Capture v1 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 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:

Harness exit: 1; report validation exit: 1.

Capture v0 — migration

✅ 55 passed / ❌ 1 non-passing / ⚠️ 101 not selected

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

❌ 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 — migration

✅ 120 passed / ❌ 1 non-passing / ⚠️ 36 not selected

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

❌ 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.

@github-actions

Copy link
Copy Markdown

Coherence

4 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 7 bullets (5 invariants, 2 requirements), 4 problems

Spec problems (spec --check)

  • packages/ai/src/otel/Otel.spec.md:7 an invariant bullet starts with <name>: <sentence>
  • packages/ai/src/otel/Otel.spec.md:8 an invariant bullet starts with <name>: <sentence>
  • Posthog-js.practice.md:20 practice add an invariant cites 187a5c5, which is no commit in this repository
  • Posthog-js.practice.md:20 practice add an invariant cites 8922ab2, which is no commit in this repository

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

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