Skip to content

feat(node): send Nodemailer mail through PostHog - #5575

Draft
Silthus wants to merge 3 commits into
emails-v2/node-integration-optionfrom
emails-v2/node-nodemailer-transport
Draft

Silthus wants to merge 3 commits into
emails-v2/node-integration-optionfrom
emails-v2/node-nodemailer-transport

Conversation

@Silthus

@Silthus Silthus commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A Node app that sends email with Nodemailer can't send that email through PostHog without rewriting every sendMail call as posthog.emails.send().

Ticket: https://github.com/PostHog/michaelr-context/issues/41 (private).

Stacked on #5574, which is stacked on #5514. Merge them first. This PR stays a draft until the email SDKs release.

Seams for review. The integration option is a seam: a convex stack builds an @posthog/convex sender on it next, and PostHog/posthog#130017 reads the name before the / of a valid X-PostHog-Integration header as the send's client. The posthog.com page for the transport is PostHog/posthog.com#21987. Check for cross-stack breaks: a marker pattern that the server reads differently, the same concept built twice, or a header that one stack sets and another doesn't read.

Merge danger

Medium, two-way door until release. It adds a new entry point, posthog-node/nodemailer, that makes real sends. Nothing that exists today imports it, and Nodemailer is a dev dependency only. After a release, the entry point and its error codes are public API.

Changes

import nodemailer from 'nodemailer'
import { createPostHogTransport } from 'posthog-node/nodemailer'

const transporter = nodemailer.createTransport(createPostHogTransport({ client: posthog }))
await transporter.sendMail({ from, to, subject, html, posthog: { template_key: 'order-shipped', idempotency_key } })
  • createPostHogTransport({ client, defaultTemplateKey? }) returns a Nodemailer transport, { name, version, send(mail, done) }. It reads each message with mail.normalize(). It doesn't import Nodemailer at run time, so posthog-node and posthog-node/nodemailer load when Nodemailer isn't installed.
  • Each message becomes one content-from-code emails.send(), with integration: 'nodemailer/<posthog-node version>' (from feat(node): let an integration name itself on an email send #5574). The SDK still does the checks, the request, the retries, and the idempotency key.
  • PostHog's options for one message go in sendMail({ posthog: { distinct_id, template_key, idempotency_key, capture_engagement } }). In TypeScript, the entry point adds posthog to Nodemailer's message options. The template key is posthog.template_key, then defaultTemplateKey, then nodemailer.
  • A message PostHog can't send fails before any request with an Error that has code: 'EPOSTHOGUNSUPPORTED' and a refusal: multiple_to, unsupported_header, unsupported_part, unsupported_priority, envelope_mismatch, inline_without_cid, or invalid_template_key. PostHog never sends part of a message.
  • A failed send fails with an Error that has code, status, detail, attr, retryable, and retry_after (seconds), with the SDK's EmailError as its cause. isRetryable is now exported inside the package for this.
  • Success calls done(null, { messageId }) with PostHog's message ID.

Evidence

  • Lane gate on the box (oxfmt, oxlint, package lint, typecheck, build, vitest run for posthog-node): green, 48 files, 1568 tests. CI on this PR: see below, because the base isn't main.
  • Red, then green: nodemailer.spec.ts failed before the change because the module didn't exist, and passes after it.
  • Test seams: a real nodemailer.createTransport(createPostHogTransport(...)) and sendMail(), through the real SDK, with fetch stubbed.
    • Shared conformance cases for email adapters: each case that applies to Nodemailer checks the exact request body and the X-PostHog-Integration and User-Agent headers. That is every rendered send, display names, cc, bcc and reply-to, attachments and inline images, the posthog options, the default template key, and the ignored headers.
    • Each refusal, with no request: the conformance refusal cases, plus AMP, iCalendar, watchHtml, alternatives, raw, a high priority, inReplyTo, list, and a sender that isn't from.
    • The error map: 400, 401, 403, 404, 409, 422, 429 for quota and for throttling (with Retry-After), 500, 503, and a network error.
  • nodemailer-absent.spec.ts loads the built posthog-node and posthog-node/nodemailer through import and require, with nodemailer unresolvable, and sends one message. A mutation that adds a runtime import 'nodemailer' makes both tests fail.
  • Type test: types/nodemailer-transport.ts checks createTransport(createPostHogTransport(...)), sendMail({ posthog }), and info.messageId, and rejects an unknown or wrongly typed posthog option.
  • Live send of case 01 through PostHog: pending.

Spec divergence. The emails spec in sdk-specs doesn't define framework adapters. This entry point uses only emails.send() and the integration option from #5574.

Release info Sub-libraries affected

Libraries affected

  • posthog-node

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)

Built by Claude Code (Opus 5.5) test-first. A GPT-6.1 Sol design pass found one simplification: the mapper now throws the refusal error itself, with no separate refusal type.

Question Answer Who Where
Where do PostHog's options go? One posthog key in sendMail(), not X-PostHog-* headers, which the transport refuses 👍 Accepted default ticket
What about a sender other than from? Refused as unsupported_header, because Nodemailer doesn't put it in the envelope and PostHog can't send it 👍 Accepted default ticket
What does success return? { messageId } only 👍 Accepted default ticket
How does TypeScript accept posthog? A module augmentation of Nodemailer's message options in the entry point's types 👍 Accepted default ticket
Which conformance cases apply? Every case except framework-derived keys, tags and metadata, tracking, and the template form, which Nodemailer has no carrier for 👍 Accepted default ticket
Does the README change? No, the guide is a posthog.com page 👍 Accepted default ticket

Involvement: 0 grilling rounds, 1 design pass (GPT-6.1 Sol, 1 finding fixed), red/green proof captured.

🤖 Generated with Claude Code

The posthog-node/nodemailer entry point exports createPostHogTransport({ client, defaultTemplateKey }). Pass it to nodemailer.createTransport() and every sendMail call sends one PostHog email through the client's emails.send, marked nodemailer/<version>. PostHog's options ride in sendMail({ posthog: { ... } }). A message PostHog can't carry fails with code EPOSTHOGUNSUPPORTED before any request, and a failed send says whether a retry with the same idempotency key can succeed. Nodemailer stays a dev dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus Silthus self-assigned this Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 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>".

@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 d1d5353: 6 Should fix, 1 Consider.

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 thread packages/node/src/nodemailer/mapper.ts Outdated
Comment on lines +85 to +87
const hasExtraPart =
Boolean(mail.watchHtml || mail.amp || mail.icalEvent || mail.raw || mail.alternatives?.length) ||
Boolean(mail.attachments?.some((attachment) => attachment.raw !== undefined))

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 · Reject nested raw MIME before normalization removes it

When html or an attachment contains both raw and content, Nodemailer gives raw precedence but normalize() drops that marker. unsupportedPart() then accepts the fallback content, and the transport successfully sends a different message. Check raw MIME on the original message before normalization so these sends fail with EPOSTHOGUNSUPPORTED.

Comment on lines +121 to +126
const { content = '', encoding, filename, contentType, cid } = attachment
return {
filename: filename || '',
content: typeof content === 'string' ? Buffer.from(content, (encoding as BufferEncoding) ?? 'utf8') : content,
...(contentType && { contentType }),
...(cid && { contentId: cid }),

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 · Validate attachment headers before discarding them

An attachment with headers: { 'Content-ID': '<logo>' } is valid Nodemailer input and lets HTML reference cid:logo. normalize() preserves those headers, but attachmentOf() drops them, so the send succeeds without content_id and breaks the delivered image. Inspect attachment headers and either map supported values or refuse the message before sending.

Comment thread packages/node/src/nodemailer/mapper.ts Outdated
Comment on lines +92 to +93
const isIgnored = (name: string): boolean => IGNORED_HEADERS.has(name) || name.startsWith('content-')
const hasHeader = Object.keys(normalizedHeaders ?? {}).some((name) => !isIgnored(name.toLowerCase()))

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 · Handle array-form headers when checking ignored names

When sendMail() receives headers: [{ key: 'Message-ID', value: '<x@example.com>' }], Nodemailer accepts the array but exposes its numeric indexes in normalizedHeaders. unsupportedHeader() checks 0 instead of Message-ID and rejects the message, although this header is explicitly ignored. Resolve array entries to their header names before checking them.

"default": "./dist/entrypoints/otel.js"
},
"./nodemailer": {
"types": "./dist/entrypoints/nodemailer.d.ts",

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 · Publish separate ESM and CommonJS declaration targets

With moduleResolution: "NodeNext", an ESM consumer loads Nodemailer's ESM declarations, but this shared .d.ts belongs to a CommonJS package and resolves the posthog augmentation against Nodemailer's separate CommonJS declarations. Consequently, sendMail({ posthog: ... }) fails type checking. Publish conditional ESM and CommonJS declaration targets.

Comment on lines +100 to +102
export interface NodemailerMail {
normalize(callback: (error: Error | null, data?: NormalizedMail) => void): void
}

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 · Match Nodemailer's transport input contract

With Nodemailer 10.3.0, this narrow normalize callback makes PostHogTransport incompatible with Transport<PostHogSentMessageInfo>. createTransport() instead selects its SMTP overload, so TypeScript accepts info.accepted.map(...) although this transport returns only messageId, causing a runtime exception. Match Nodemailer's input type and narrow after normalization.

Comment on lines +33 to +38
const refusal =
unsupportedPart(mail) ??
unsupportedHeader(mail) ??
envelopeMismatch(mail, [...to, ...cc, ...bcc]) ??
unsupportedPriority(mail.priority) ??
inlineWithoutCid(mail.attachments)

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 · Refuse messages whose REQUIRETLS policy cannot be enforced

A message with requireTLSExtensionEnabled: true passes every refusal check, and the mapper drops that setting before sending. An application migrating from SMTP therefore receives success without preserving its explicit REQUIRETLS delivery constraint, silently weakening its security policy. Reject this option unless PostHog can enforce the same requirement.

Comment thread packages/node/src/nodemailer/mapper.ts Outdated
const { content = '', encoding, filename, contentType, cid } = attachment
return {
filename: filename || '',
content: typeof content === 'string' ? Buffer.from(content, (encoding as BufferEncoding) ?? 'utf8') : content,

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.

P3 · Preserve attachments that Nodemailer already encoded as Base64

For binary attachments, Nodemailer's normalize() returns Base64 content with encoding: 'base64'. attachmentOf() decodes it into a new Buffer, then EmailSender encodes that Buffer back into Base64. Attachment-heavy sends incur redundant allocations and synchronous CPU work on the event loop; pass Base64 strings through directly.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Node SDK acceptance v2

Commit: 84935ad
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 and others added 2 commits November 5, 2026 12:53
…ransport for createTransport

An attachment's raw source and an attachment's own headers are refused, so PostHog never sends other bytes or loses a Content-ID. Headers passed as a list of { key, value } are read by name. The transport's normalize data is unknown to TypeScript, so createTransport() picks the transport overload and a send resolves to { messageId }. Base64 attachment content from normalize() goes to the SDK as it is.

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

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