Skip to content

feat(node): read an email's status with emails.get - #5372

Closed
Silthus wants to merge 28 commits into
PostHog:mainfrom
Silthus:feat/node-emails-get
Closed

Silthus wants to merge 28 commits into
PostHog:mainfrom
Silthus:feat/node-emails-get

Conversation

@Silthus

@Silthus Silthus commented Oct 22, 2026 •

Copy link
Copy Markdown

Problem

After a send, an app cannot read back what happened to the email, so it cannot show "sent" or "bounced" next to a user.

This PR adds posthog.emails.get(id) from the shared SDK contract PostHog/posthog#120921, "Read":

const { data, error } = await posthog.emails.get(sent.id)
// data: { id, status, createdAt, statusAt, reason? }

Slice 11 of strand Silthus/posthog#536, map Silthus/posthog#461.

Stacked on #5373 (code-form fields), on #5371 (slice 10), on #5312. This fork PR targets main, so its diff includes all three. This layer alone: feat/node-emails-code-fields...feat/node-emails-get.

Server dependency, not built yet: GET /api/projects/@current/messaging/emails/{message_id}/ is slice 08 of the send-api strand (the spec's pin). This PR is built against the contract at the HTTP seam.

Impact

Pays into Grow Workflows through billed workflows_emails from API sends (not measured yet: the send attribution of PostHog/posthog#120628 plus its insight). In scope: Messaging, email. Expected move: small, up: the status read lets a Resend site that shows delivery state move. Run cost: n/a.

Merge Danger

Caution

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

  • Adds posthog.emails.get() to the public API. Once released, the status names and fields are a contract callers branch on.
  • A read changes nothing on the server, never retries and never throws. Tests at the HTTP boundary pin the path, the mapping and each error; not yet run against a live server, and the read route is not built yet (gap below).

Changes

Kind Files Lines (this layer)
Production 3 +132 −19
Tests 2 +126 −1
References and changeset 2 +51 −1
  • emails.get(id) reads GET messaging/emails/{id}/ on the app host with the secret key, the id as one encoded path segment.
  • Returns { data: { id, status, createdAt, statusAt, reason? }, error: null } or { data: null, error }, with the same error names as send. reason is left out when PostHog gives none.
  • Never retries: not on 404, 429, 503 or a network error. A new email answers 404 not_found for its first seconds, and the JSDoc says so.
  • The JSDoc says status is not Resend's last_event.
  • Refuses before any request: missing_secret_key (naming the hog_flow:read scope), missing_app_host, client_disabled, client_closed after shutdown(), as send does, and invalid_message_id for an empty id or one that can't be encoded into a URL, named validation_error as the contract's local checks are.

Parity with posthog-python

Contract field posthog-node posthog-python
id, status, created_at, status_at, reason? id, status, createdAt, statusAt, reason? Not built yet (contract: same names in snake_case)
status values queued, waiting, unknown, skipped, failed, sent, delivered, bounced, complained Not built yet
Retries None Contract: none

Evidence

  • Test seams: the emails namespace on a real client, fetch mocked by FakeEmailsApi, which now answers the status route (emails.spec.ts, describe('get')).
  • What the tests pin: path, origin, Authorization, User-Agent and X-PostHog-Migrated-From; the field mapping with and without reason; one request and the error for 404, 503, 429, a network failure and an answer without a status; the refusals before any request, with the scope in the missing-key message and a lone-surrogate id; an id with / and ? read as one path segment.
  • Red first: 12 cases failed before get, then green. The review fix's 2 cases failed before it, then green.
  • Local gate on fc5dc171 (on the leased compute box): node vitest (1350 passed), the edge build tests (1350 passed), tsc for the build and the type tests, oxlint, oxfmt, build and generate-references; core vitest.
  • Owed proofs:
    • Live run (send, then get until sent): gap. The read route is slice 08, not built yet.

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) decided the read path in the spec. Built by Claude Code (Claude Opus 5.5) test-first, as one build lane of the map.

Question Answer Who Where
Read path GET messaging/emails/{message_id}/, hog_flow:read 👍 Accepted default spec, Contract pins
An unknown status value from a newer server Passed through as given, typed as EmailStatus 🤖 Agent this PR
get after shutdown() client_closed, as get already refuses with client_disabled; the contract names only a send 🤖 Agent this PR

Open: none.

Involvement: spec decisions by Michael; one strand review by GPT-6.1 Sol over #5312, #5371 and #5372 (2 findings: 1 fixed in #5371, 1 rejected as behavior that predates this strand) and a final read of the fix with no findings; a second final read after the restack on #5373 and the invalid_message_id name fix, 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 12 commits October 23, 2026 13:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upload after `template_outdated` now sets `if_not_superseded`. When a newer
deploy replaced the file's content between the send and the upload, PostHog
changes nothing and the one resend returns the `template_superseded` warning.
`uploadTemplate` still publishes replaced content, so it stays the rollback path.

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

An upload of a template file now sends source.repository, source.path and
source.ref without configuration. Each part comes from the explicit source
option, then from GitHub Actions, GitLab CI, Vercel or Railway variables, then
from the nearest git checkout above the file. The checkout is read as files,
without running git, and credentials in a remote URL never leave the process.
Detection never fails a send.

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

Source detection read any file named like a git file, so a pipe in a .git
directory could hang a send. It now reads only regular files up to 4 MiB.
A Vercel provider named like an object property, such as "constructor", no
longer throws, and a quoted origin URL followed by a comment is read the way
git reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog.emails.send now takes the flat template form of the shared SDK
contract: templateId with templateData, posted to the template send
route. Every send carries an Idempotency-Key, a UUID v4 unless the
caller gives one, and retries network errors, an in-flight key,
throttling and 503 with the same key. A monthly quota is never retried.

Errors carry Resend's names, why and fix. New client options appHost,
maxRetries and migratedFrom. The secret key only goes to the app host:
derived for PostHog Cloud, required for any other host.

The unreleased template field, templateFile() and uploadTemplate() go
away. The content hash stays for local templates.

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

A send to a workflow that isn't active answers 400 transactional_email_paused,
a validation_error. The idempotency key docs now say a replay survives a paused
workflow or a changed default sender, and still refuses a deleted or changed
template or a project with no default sender (PostHog/posthog#120921).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A send with options that are not an object returns invalid_form instead
  of throwing.
- A non-string idempotencyKey returns invalid_idempotency_key.
- A maxRetries that is not a whole number from 0 falls back to the client
  option, then to 2, so NaN or Infinity can't retry forever.
- A queued 202 whose body breaks off while it is read is a network error,
  so the send retries with the same key and returns the first id.
- A network error keeps its cause in the message.
- The secretKey docs say to turn off local evaluation for a key that can't
  read feature flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fused connection's cause

- A send with an option posthog-node doesn't take, such as Resend's
  scheduledAt, returns invalid_form on that field instead of sending
  without it.
- A refused connection to a host with two addresses keeps both causes in
  the network error message.
- Core parses a JSON answer with trySafe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the feat/node-emails-get branch from 97b03b0 to 1aab284 Compare October 23, 2026 14:53
Silthus and others added 8 commits October 24, 2026 12:38
A send after shutdown(), or while it drains, returns client_closed,
named application_error, before any request, as the shared contract's
2026-10-24 entry asks.

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

posthog.emails.send takes the code form of the shared SDK contract:
templateKey, subject, html and/or text, with optional from, replyTo and
captureEngagement. The types refuse a send that mixes the two forms.

Without templateData the content is rendered and goes to the send route
as is, never uploaded. With templateData it is a local template: the
SDK sends its content hash, and on 409 template_outdated uploads it by
key with if_code_managed and sends once more with the same idempotency
key. An upload refused because the key was edited in PostHog returns
its error and is never retried.

Template sends by id move to the send route as template.id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local template send read its options after hashing the content, so a
caller that changed the same options object before the send finished
sent to the new recipient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d's data

- A template send with from, replyTo, subject, html or text returns
  invalid_form on that field, instead of sending without it.
- A code send without a subject, or without html or text, returns
  invalid_form before any request.
- A local template send copies its request body before it hashes the
  content, so a caller that changes templateData during the send changes
  nothing.
- The round-trip tests expect a fixed content hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Silthus and others added 7 commits October 24, 2026 12:40
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>
posthog.emails.get(id) reads GET messaging/emails/{id}/ on the app host
with the secret key and returns { id, status, createdAt, statusAt,
reason? } or the error. It never retries: a new email answers not_found
for its first seconds, so the caller reads again later.

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

- emails.get with an id that has a lone surrogate returns
  invalid_message_id instead of throwing.
- Without a secret key, emails.get names the hog_flow:read scope.
- The EmailError code docs list invalid_message_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the feat/node-emails-get branch from 1aab284 to fc5dc17 Compare October 24, 2026 12:43
@Silthus Silthus closed this Oct 25, 2026
Silthus added a commit to Silthus/posthog-js that referenced this pull request Oct 25, 2026
Moves PostHog#5372 (feat/node-emails-get at fc5dc17) onto the code
fields slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to Silthus/posthog-js that referenced this pull request Oct 25, 2026
Moves PostHog#5372 (feat/node-emails-get at fc5dc17) onto the code
fields slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to Silthus/posthog-js that referenced this pull request Oct 25, 2026
Moves PostHog#5372 (feat/node-emails-get at fc5dc17) onto the code
fields slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus added a commit to Silthus/posthog-js that referenced this pull request Oct 25, 2026
Moves PostHog#5372 (feat/node-emails-get at fc5dc17) onto the code
fields slice.

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