Skip to content

feat(node): send cc, bcc, headers, tags and attachments from code - #8

Closed
Silthus wants to merge 5 commits into
emails-v2/03-local-templatefrom
emails-v2/04-code-fields
Closed

Silthus wants to merge 5 commits into
emails-v2/03-local-templatefrom
emails-v2/04-code-fields

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

A team moving from Resend sends receipts with a PDF, copies to a shared inbox, and tags for their own analytics. This PR adds cc, bcc, headers, tags and attachments to the code form, for rendered content and local templates alike.

Stacks on #7. Needs the server's fields and attachments arms of send/email/. Part of map Silthus/posthog#461.

Impact

Same as the stack: Grow Workflows, billed workflows_emails from API sends. These fields remove the most common reason a Resend caller can't switch.

Merge Danger

Caution

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

  • New public option names and wire fields, and attachment content (customer files) through the SDK. The SDK never logs content and no error repeats it; the limits stay on the server.

Changes

Kind Files Lines (against #7)
Production 3 +126 −6
Tests 2 +116 −0
References and changesets 2 +100 −0
  • Code form only. A template send with one of these fields returns invalid_form on that field instead of sending without it. The types refuse them there too.
  • On the wire, each left out when absent: cc and bcc (a string becomes a one-item list), headers and tags as given, attachments as { filename, content, content_type?, content_id? } with a Buffer or Uint8Array as Base64.
  • Once per send. Attachments are encoded and copied when the send is prepared, before retries and the local template's hash. The upload never carries these fields.
  • Refused before any request, as invalid_attachment named validation_error with the field:
    • attachments that isn't a list (attachments), or an item that isn't an attachment object, such as a file's bytes or a Base64 string passed as the item itself (attachments__<n>).
    • A Resend path (attachments__<n>__path), or a key the SDK doesn't send, such as content_id (attachments__<n>__<key>).
    • Content that isn't a Buffer, Uint8Array or string, or no content (attachments__<n>__content).
  • Values are checked on the server. A blank cc, a missing filename or a header PostHog sets itself, such as From or Content-Type, comes back from the server as validation_error on that field.
  • EmailAttachment and EmailTag are exported.

Evidence

  • Test seams: posthog.emails.send() on a real client with an injected fetch; the accepted and refused shapes as type tests.
  • What the tests pin: the wire mapping of every field, with Base64 for Buffer and Uint8Array and a 40 KB file; the same fields on both sends of a local template and none on its upload; each invalid_attachment refusal with its field, including a Buffer and a Base64 string passed as the item; each field refused on the template form.
  • Lane gate on 230480c52: node vitest and edge (1526 tests), core vitest, check-types, oxlint, oxfmt, build, references committed, pnpm check:public-api clean.
  • Shadow diff, feat(node): upload local email templates by hash on send #7's head 3cc39e036 against 475d6cbe6: the same 22 invented inputs through posthog.emails.send() with scripted replies, recording every request and result. 18 are identical. The 4 that differ are the cases this PR adds: the new fields on both sends of a local template and never on its upload, and the attachment refusals. 230480c52 only adds the refusal of a non-object item.
  • CI: call-flags-project / add-to-project-board fails on every fork PR (recorded as a ci gap).
  • Owed proofs:
    • Live run: gap. Needs the server's code-field slice; lane dj3 runs the live run of the stack in this round.
    • Cross-version: gap, accepted. A server without these fields answers 400 invalid_request on the field, which maps to validation_error.
    • Production shape: gap until read-only production access is available.

Decisions

Question Answer Who Where
The public names Resend's: cc, bcc, headers, tags, attachments with filename, content, contentType, contentId 🤖 Agent pending approval
A Resend attachment path Refused, because PostHog can't fetch files; the caller reads the file and passes content 🤖 Agent pending approval
The template form Takes none of these fields and refuses each on its name, since a template made in PostHog sets its own content 🤖 Agent pending approval
Where values are checked The server checks values such as a blank cc or a missing filename; the SDK checks only the shape it needs to encode 🤖 Agent pending approval

No open questions.

Follow-ups:

  • The sdk-specs emails spec has no requirement for these fields yet; it needs one before a third SDK adds them.
  • posthog-python also checks values such as a blank cc before any request; Node leaves them to the server. Both answer validation_error on the same field.

Involvement: carries the PostHog#5373 review rounds. This rebuild: one full-scope GPT-6.1 Sol read at e9195432e (1 finding, fixed by #7's bf8cfa2b3); a QA swarm at 0b6f101de with a full two-family panel (GPT-6.1 Sol on five lenses, Claude Opus 5.5 fresh eyes: 5 fixed, 1 half rejected) and a narrow round on the fixes (1 fix); 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

@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from a370487 to 989b6c5 Compare October 25, 2026 03:24
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch 2 times, most recently from 33b1193 to 4a62a77 Compare October 25, 2026 03:39
@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from 989b6c5 to d509711 Compare October 25, 2026 03:39
@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: 230480c
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/04-code-fields branch 2 times, most recently from f98f32c to fb9640e Compare October 25, 2026 19:11
@Silthus
Silthus force-pushed the emails-v2/03-local-template branch 2 times, most recently from da59e27 to 4e4bcb2 Compare October 31, 2026 08:32
@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from fb9640e to e919543 Compare October 31, 2026 08:41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from e919543 to 0b6f101 Compare October 31, 2026 08:43
Silthus and others added 4 commits November 1, 2026 16:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An attachment with a key outside filename, content, contentType and
contentId, such as the wire name content_id, lost that key without an
error, so an inline image could go out with a broken cid. The send now
returns invalid_attachment on attachments__<n>__<key> before any request.

One converter checks and maps each attachment, which drops the separate
check pass and the EmailAttachment cast. The headers doc now says PostHog
refuses the headers it sets itself, as the server does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Buffer or a Base64 string passed as the attachment itself was spread key
by key: a large file took seconds, then failed on a byte index, or threw a
RangeError. Refuse any value that isn't an attachment object on
attachments__<n> first, and look up unknown keys against the keys the SDK
takes.

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#5514.

@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