Skip to content

feat(cdp): record skipped, failed and delivered transactional emails - #124751

Merged
trunk-io[bot] merged 1 commit into
posthog/messaging-email-status-writersfrom
posthog/messaging-email-status-outcomes
Oct 28, 2026
Merged

trunk-io[bot] merged 1 commit into
posthog/messaging-email-status-writersfrom
posthog/messaging-email-status-outcomes

Conversation

@mayteio

@mayteio mayteio commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Problem

This PR completes the Node writes. #123932 reads the email_status table.

Refs #120921.

Changes

  • A skipped send is recorded as failed with the skip reason, for example recipient_suppressed, recipient_opted_out or frequency_capped.
  • A run that fails or is canceled is recorded as failed, with the run's error kind or canceled as the reason.
  • A job whose workflow or team is gone when it is dequeued is recorded as failed with workflow_unavailable.
  • A run failure ranks below the email step's own failure. A provider rejection keeps provider_error after the run aborts on it.
  • The SES webhook records delivered, bounced (with hard, soft or unknown) and complained.
  • Only transactional sends carry the ph_email_status SES tag, and the webhook only updates the status for a To recipient. A bounce from a cc or bcc address does not change it.
  • The SES webhook waits at most 1s for the status rows.
  • A To header of an unexpected shape turns off the status update only, never the processing of the whole SES record.
  • Mechanical: classifyError is exported, and the hog function handler and the hog flow executor take the status service.

How did you test this code?

Test rationale: each path that ends an email has a case, because a missed path leaves the email at queued with no error.

  • email-status.service.test.ts: each run outcome, a provider rejection that then fails its run, and a job whose workflow is gone.
  • email.service.test.ts and hog_function.test.ts: the row for each skip reason, and none for a test send or a workflow email.
  • ses.test.ts and email-tracking.service.test.ts: the status for each SES event, the To match, and the tag check.

Ran locally: the Jest suites under src/cdp/services/messaging/, the hog flow worker, the hog function handler, the invocation results service, and tsc with no errors in src/cdp.

Not checked yet: a send end to end on the local stack.

Release status

  • No feature flag controls this change

Docs update

None. #123932 documents the endpoint.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: PostHog Desktop (Claude Code), Claude Opus 5.5

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Duplication (Python) — 5 new duplicated blocks (worst 102 tokens)

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
products/warehouse_sources/backend/temporal/data_imports/sources/gladly/gladly.py:10 products/warehouse_sources/backend/temporal/data_imports/sources/zep/zep.py:5 15 102
products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/schemas.py:298 products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/schemas.py:381 45 99
products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/schemas.py:299 products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/schemas.py:461 44 97
products/warehouse_sources/backend/temporal/data_imports/sources/mollie/mollie.py:3 products/warehouse_sources/backend/temporal/data_imports/sources/recruitee/recruitee.py:3 14 96
products/warehouse_sources/backend/temporal/data_imports/sources/recruitee/recruitee.py:2 products/warehouse_sources/backend/temporal/data_imports/sources/vendr/vendr.py:1 13 94
⚠️ Duplication (TypeScript) — 1 new duplicated block (worst 115 tokens)

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
products/workflows/frontend/OptOuts/CustomerIOImportModal.tsx:175 products/workflows/frontend/OptOuts/CustomerIOImportModal.tsx:332 25 115
🚨 Comment density — 8% of added code lines are comments (49 of 645)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/cdp/services/messaging/email-status.service.ts 21 88
nodejs/src/cdp/services/messaging/helpers/ses.ts 15 76
nodejs/src/cdp/services/hogflows/actions/hog_function.ts 3 23
nodejs/src/cdp/services/messaging/email-status.service.test.ts 3 119
nodejs/src/cdp/services/hogflows/hogflow-executor.service.ts 2 8
nodejs/src/cdp/services/messaging/email-tracking.service.test.ts 2 39
nodejs/src/cdp/services/messaging/helpers/ses.test.ts 2 68
nodejs/src/cdp/services/messaging/email-tracking.service.ts 1 21

This check does not block merging. It updates on every push and clears when the share drops.

@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 e417c58: 3 Should fix.

This Standard review started from the posthog-review label. Comment @posthog review --deep for a Deep review, or @posthog help to see all commands.

See this review and its findings in PostHog Review.

Comment on lines +274 to +277
case 'Complaint':
return reachesTo(rec.complaint.complainedRecipients.map((r) => r.emailAddress))
? { status: 'complained', timestamp: rec.complaint.timestamp }
: null

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 · Exclude not-spam corrections from complained status

When SES sends a Complaint with complaintFeedbackType: 'not-spam' for the To recipient, resolveEmailStatusUpdate records complained even though this feedback explicitly reverses a spam classification. That highest-ranked status then overrides delivery permanently. Apply the same case-insensitive not-spam exclusion used by the suppression path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6452361. A not-spam feedback type, compared case-insensitively like the suppression path, no longer records complained. Covered in ses.test.ts.

Comment on lines +278 to +279
default:
return null

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 · Record asynchronous SES rejections as terminal failures

When SES accepts a send and subsequently emits Reject, such as after detecting a virus, resolveEmailStatusUpdate returns null. The synchronous send already recorded sent, so callers retain that status despite SES refusing delivery; existing webhook handling only records a failure metric and log. Handle Reject with a failure version that supersedes sent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6452361. A Reject record now writes failed with reason provider_rejected at its own rank between sent and delivered, so it replaces sent. Covered in ses.test.ts and the rank-order test.

Comment on lines +58 to +62
failed: 2n,
sent: 3n,
delivered: 4n,
bounced: 5n,
complained: 6n,

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 · Preserve the existing persisted status ranks

With EMAIL_STATUS_ENABLED, existing sent rows encode rank 2. This change reuses rank 2 for failed, so a skipped or rejected retry with the same invocation ID produces a larger version and replaces the earlier sent row, violating the ordering guarantee across upgrades. Preserve existing ranks and allocate run failures an unused intermediate version range.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 664757b (#124736). The ranks are set once there with gaps (queued 0, failed 2, sent 3, delivered 5, bounced 6, complained 7), and this PR only adds run failures at 1 and provider rejections at 4. No rank changes between the two PRs.

@posthog posthog Bot removed the posthog-review Run a Standard PostHog Review label Oct 28, 2026
@trunk-io

trunk-io Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

- A skipped send is recorded as failed with its skip reason.
- A run that fails or is canceled, or whose workflow or team is gone at dequeue, is recorded as failed. A run failure ranks below the email step's own failure, so `provider_error` survives the abort that follows it.
- The SES webhook records delivered, bounced and complained for transactional sends, which carry the `ph_email_status` tag, and only for a `To` recipient. A not-spam correction is not a complaint.
- An SES rejection after the send was accepted is recorded as failed with `provider_rejected`, ranked above `sent`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mayteio
mayteio force-pushed the posthog/messaging-email-status-outcomes branch from e417c58 to 6452361 Compare October 28, 2026 12:37
@mayteio
mayteio marked this pull request as ready for review October 28, 2026 12:43
@parameterai

parameterai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Risk: No findings

This PR records terminal statuses for transactional emails (skipped sends, failed/canceled runs, workflow-unavailable jobs, and SES delivery/bounce/complaint/reject events) into the email_status ClickHouse table via the existing Kafka output, and threads the EmailStatusService into the SES webhook, the hog function email handler, the hog flow executor, and the invocation-results sink. The main security surface — the new state-changing SES webhook write — is properly layered behind SNS signature verification with an allowlisted cert host, a TopicArn allowlist, an HMAC-signed (timing-safe-verified) tracking code, and a send-time-only SES tag, so no forged or cross-tenant status rows are possible; I found no security vulnerabilities.

Sentinel reviewed 6452361 · Review settings

@mayteio mayteio added the stamphog Request AI approval (no full review) label Oct 28, 2026 — with PostHog
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team October 28, 2026 12:44

@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 risky changes have assurance through owning-team authorship and STRONG familiarity, with targeted test coverage. The current code addresses all three historical review concerns; no remaining substantive concerns or policy refusal grounds were found.

  • Author wrote 71% of the modified lines and has 35 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 260L, 11F substantive, 713L/18F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (713L, 18F, single-area, feat)
stamphog 2.6.1 .stamphog/policy.yml @ 6452361 · reviewed head 6452361

@mayteio

mayteio commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

/trunk merge

@trunk-io

trunk-io Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@trunk-io
trunk-io Bot merged commit a17f62e into master Oct 28, 2026
325 checks passed
@trunk-io
trunk-io Bot deleted the posthog/messaging-email-status-outcomes branch October 28, 2026 18:19
@deployment-status-posthog

deployment-status-posthog Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-10-28 18:50 UTC Run
prod-us ✅ Deployed 2026-10-28 19:06 UTC Run
prod-eu ✅ Deployed 2026-10-28 19:12 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stamphog Request AI approval (no full review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants