Skip to content

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

Draft
Silthus wants to merge 4 commits into
feat/node-emails-code-fieldsfrom
feat/node-emails-get
Draft

Silthus wants to merge 4 commits into
feat/node-emails-code-fieldsfrom
feat/node-emails-get

Conversation

@Silthus

@Silthus Silthus commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

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

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 PostHog#5312, PostHog#5371 and PostHog#5372 (2 findings: 1 fixed in PostHog#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 PostHog#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 4 commits October 24, 2026 12:40
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>
@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.

@github-actions

Copy link
Copy Markdown

Node SDK compliance v2

Commit: fc5dc17
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.

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