Skip to content

feat(node): send cc, bcc and attachments from code - #5514

Open
Silthus wants to merge 12 commits into
emails-v2/01-send-inline-htmlfrom
emails-v2/04-code-fields
Open

Silthus wants to merge 12 commits into
emails-v2/01-send-inline-htmlfrom
emails-v2/04-code-fields

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown

Problem

An app moving its email from Resend to PostHog can't send a receipt with a PDF, or copy finance on an invoice. The code form of posthog.emails.send() (#5509) takes rendered html/text only.

Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with migratedFrom in project 2.

Note

Merge danger: low, two-way door. New optional fields on an unreleased API. Release only after the server half deploys; until then a send with these fields gets a 400 that names the field, never a silent drop.

Changes

Re-cut onto #5509: the local-template work of #5513 (parked) is gone, and headers and tags wait for a later slice.

  • cc and bcc: one address or a list. A string becomes a one-item list. An empty list is left out, as feat(node): send email content from code with emails.send #5509 does for replyTo.
  • attachments: { filename, content, contentType?, contentId? }. A Buffer or Uint8Array becomes Base64 once, before the retry loop: natively on Node, through btoa on the edge runtime. A string is sent as is. An empty list is left out, so an older server still sends the email.
  • Refused before any request as invalid_attachment, with the field: a non-list, a non-object item, a path (Resend style), an unknown key, a missing or empty filename, a contentType or contentId that isn't a string, or content that is missing or isn't Base64. No error repeats the content.
  • Template form: templateId with any of the three returns invalid_form, and the types refuse it.
  • The server holds the limits (10 addresses, 10 files, 512 KB). EmailAttachment is exported.

Test seams: posthog.emails.send() on a real client with an injected fetch, and the accepted and refused shapes as type tests.

Proof gate passes at 8dd465b: CI green, review clean, and all three owed proofs captured. StampHog approved 0d5386b; a new review is requested for 8dd465b.

The Python twin is PostHog/posthog-python#1172.

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)

Written by Claude Opus 5.5 (claude-opus-5-5) in Claude Code, for Michael. The re-cut cherry-picked the old commit ccb14b502 onto #5509's head and dropped the local-template, headers and tags parts.

Question Answer Who Where
Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice 👍 Accepted default comment
How does StampHog approve an SDK draft? Mark it ready for the stamp, then back to draft 👍 Accepted default comment

Involvement: 2 questions answered. Reviews: GPT-6.1 Sol and Opus 5.5, rounds 1 and 2 and six final reads; PostHog Review; StampHog. Proof captured: live run, cross-version and shadow diff.

🤖 Generated with Claude Code

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed ccb14b5: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment on lines +593 to +598
function base64Of(bytes: Uint8Array): string {
const chunks: string[] = []
for (let start = 0; start < bytes.length; start += BASE64_CHUNK_BYTES) {
chunks.push(String.fromCharCode(...bytes.subarray(start, start + BASE64_CHUNK_BYTES)))
}
return btoa(chunks.join(''))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Use native Base64 encoding for Node attachment bytes

Sending multi-megabyte attachments blocks the event loop in base64Of, which spreads every byte into JavaScript arguments and builds intermediate strings. A 5 MiB Buffer took about 366 ms versus 3 ms with Buffer.toString('base64'); concurrent sends serialize this work and stall unrelated requests. Use native encoding on Node with an edge-compatible fallback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85cb4d9: Node encodes with Buffer.toString('base64'). Only the edge runtime, which has no Buffer, still uses btoa.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Node SDK acceptance v2

Commit: 8dd465b
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 added this pull request to stack #5516 November 2, 2026 10:23
The code form of posthog.emails.send() takes cc, bcc and attachments.
A single address becomes a one-item list on the wire. Attachment bytes
are Base64 encoded once, before the retry loop. The template form
refuses these fields before any request.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus
Silthus force-pushed the emails-v2/04-code-fields branch from ccb14b5 to a88a83d Compare November 3, 2026 08:12
@Silthus
Silthus removed this pull request from stack #5516 November 3, 2026 08:12
@Silthus
Silthus changed the base branch from emails-v2/03-local-template to emails-v2/01-send-inline-html November 3, 2026 08:12
@Silthus
Silthus added this pull request to stack #5526 November 3, 2026 08:12
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Replay incident risk check

This diff touches code involved in past incidents. This is a heads-up, not a verdict: read the matched sections of INCIDENTS.md and answer their review questions before merging.

For a judgment on whether this diff has the same failure mode, run the replay-incident-risk skill locally: open a Claude session in the repo root and ask it to review your diff with that skill. Or run node .agents/skills/replay-incident-risk/check.mjs and answer the review questions for each matched class yourself.

Matched 4 past incident pattern(s) (diff vs origin/emails-v2/03-local-template):

## rrweb serialization and snapshot correctness [path match]
Serializer bugs corrupt replays silently: no exception, no volume change. In May 2026 a cssText spec quirk silently dropped layout styles for sites using CSS custom properties. Test against framework-generated CSS (Chakra/Panda, Emotion speedy, Tailwind) in a real browser.
  Touched paths:
    - packages/rrweb/rrweb-snapshot/src/index.ts
    - packages/rrweb/rrweb-snapshot/src/record.ts
    - packages/rrweb/rrweb-snapshot/src/snapshot.ts
    - packages/rrweb/rrweb-snapshot/test/snapshot.test.ts
    - packages/rrweb/rrweb/src/record/idle.ts
    - packages/rrweb/rrweb/src/record/index.ts
    - packages/rrweb/rrweb/src/record/mutation.ts
    - packages/rrweb/rrweb/src/record/observer.ts
    - ...and 3 more
  Added lines matching risk patterns:
    - packages/rrweb/rrweb/src/record/stylesheet-manager.ts: /cssText/ -> // `_cssText` arrives, so attributes changed since (such as `media`) must
  Read: .agents/skills/replay-incident-risk/INCIDENTS.md#class-4-rrweb-serialization-and-snapshot-correctness

## Masking, privacy, and consent [path match]
A masking or consent regression records data customers promised their users we would not store: passwords, PII, opted-out sessions. Nothing throws and no volume moves, so no alert can fire, and recorded data cannot be un-stored. Add a real-browser test asserting the sensitive value is absent from the emitted snapshot bytes.
  Touched paths:
    - packages/rrweb/rrweb-snapshot/src/index.ts
    - packages/rrweb/rrweb-snapshot/src/record.ts
    - packages/rrweb/rrweb-snapshot/src/snapshot.ts
    - packages/rrweb/rrweb-snapshot/test/snapshot.test.ts
  Added lines matching risk patterns:
    - packages/rrweb/rrweb/src/record/mutation.ts: /maskAllInputs|maskInputOptions|maskTextSelector|maskInputFn|maskTextFn/ -> maskTextSelector: this.maskTextSelector,
  Read: .agents/skills/replay-incident-risk/INCIDENTS.md#class-8-masking-privacy-and-consent-proactive-no-incident-yet

## Capture transport and retry behavior [content match]
This code runs on every customer page. Retries without backoff and jitter become a coordinated fleet-wide flood against capture during any ingestion blip; queue bugs become silent event loss or duplicates. Walk a page through a 30-minute capture outage and count the requests.
  Added lines matching risk patterns:
    - packages/rrweb/rrweb-snapshot/src/snapshot.ts: /backoff|jitter/ -> // new href (Chrome 156 does), so a changed link is also checked on a backoff.
  Read: .agents/skills/replay-incident-risk/INCIDENTS.md#class-9-capture-transport-and-retry-behavior-proactive-no-incident-yet

## Release and delivery channel integrity [path match]
The Nov 2025 supply-chain compromise started with a pull_request_target workflow checking out PR-author code. Never reintroduce that pattern, don't add package lifecycle scripts, and keep publish credentials on OIDC trusted publishing.
  Touched paths:
    - .github/workflows/bundled-size.yaml
    - .github/workflows/codeql.yml
    - .github/workflows/coherence.yml
    - .github/workflows/dependabot-changeset.yml
    - .github/workflows/error-tracking-harness.yml
    - .github/workflows/generate-references.yml
    - .github/workflows/library-ci.yml
    - .github/workflows/new-pr.yml
    - ...and 11 more
  Read: .agents/skills/replay-incident-risk/INCIDENTS.md#class-7-release-and-delivery-channel-integrity

This check is advisory. It flags resemblance to past incidents, not correctness.
For a judgment pass on whether this diff has the same failure mode, run the
`replay-incident-risk` skill in a Claude session from the repo root, or answer the
review questions in the matched sections of .agents/skills/replay-incident-risk/INCIDENTS.md yourself.

@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 writing on behalf of Michael

Decisions for the attachments slice:

  • 👍 Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice.
  • 👍 How does StampHog approve an SDK draft? StampHog doesn't review drafts, so this PR goes ready only for the stamp, then back to draft.

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Array.map skips holes, so a sparse list sent null attachments past
the check. A retry test now pins the bytes and key of the first
attempt for a send with attachments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Silthus Silthus changed the title feat(node): send cc, bcc, headers, tags and attachments from code feat(node): send cc, bcc and attachments from code Nov 3, 2026
@Silthus
Silthus marked this pull request as ready for review November 3, 2026 10:32
@Silthus
Silthus requested a review from a team as a code owner November 3, 2026 10:32

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approved — this change needs a human reviewer.

Re-add the stamphog label to request another review once you have addressed this.

Independent assurance and STRONG author familiarity cover the risky areas, but @PostHog's substantive inline concern remains unfixed. Address the encoding concern in packages/node/src/emails/emails.ts with native Node encoding and an edge-compatible fallback, or obtain a non-author acceptance in the thread, before re-requesting.

  • Author wrote 100% of the modified lines and has 0 merged PRs in these paths (familiarity STRONG).
  • PostHog Review (Standard) reviewed the current head: no findings.
  • @PostHog, packages/node/src/emails/emails.ts: the open performance concern remains unaddressed. base64Of still spreads byte chunks into JavaScript arguments, builds intermediate strings, and calls btoa rather than using native Node encoding; attachment conversion runs synchronously before the request.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 171L, 4F substantive, 290L/7F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1c-medium (290L, 7F, two-areas, feat)
stamphog 2.7.0 .stamphog/policy.yml @ 5a1a78e · reviewed head 5a1a78e

Silthus and others added 4 commits November 3, 2026 10:39
…nto emails-v2/04-code-fields

# Conflicts:
#	packages/node/src/__tests__/emails.spec.ts
#	packages/node/src/emails/emails.ts
stamphog[bot]

This comment was marked as outdated.

@stamphog
stamphog Bot dismissed their stale review November 4, 2026 08:38

New commits were pushed — dismissing the stamphog approval from an earlier head. This PR no longer qualifies for automatic review.

@Silthus
Silthus marked this pull request as ready for review November 4, 2026 10:12

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

The disclosed API and write-path changes have current-head review assurance and a STRONG-familiarity author. The historical performance concern is addressed by native Node encoding, with regression coverage and no remaining substantive concerns found.

  • Author wrote 100% of the modified lines and has 0 merged PRs in these paths (familiarity STRONG).
  • PostHog Review (Standard) reviewed the current head: no findings.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 211L, 4F substantive, 377L/7F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (377L, 7F, two-areas, feat)
stamphog 2.7.0 .stamphog/policy.yml @ 8dd465b · reviewed head 8dd465b

This branch has not been deployed

No deployments
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