Repository navigation
feat: add the emails capability spec #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0584866
feat: add the emails capability spec
mayteio 5c21a93
docs: describe the email error name as a category
mayteio e5bb7df
fix: address review on the emails spec
mayteio d25d6ef
docs: qualify the email dedup promise and Python shutdown conformance
mayteio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| @public @canonical_behavior @acceptance @emails @server | ||
| Feature: Emails | ||
| Canonical contract: openspec/specs/emails/spec.md | ||
| These scenarios require email harness bindings before integration selection. | ||
|
|
||
| Background: | ||
| Given an isolated SDK instance | ||
| And the mock PostHog server is reset | ||
|
|
||
| # Requirement: Send request | ||
|
|
||
| Scenario: A send posts the template and its data to the app host | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will accept the next email send with message ID "msg_1" | ||
| When the caller sends to "user@example.com" with template ID "0190F6D2-7C1E-7B3A-9F5E-1C2D3E4F5A6B", template data {"name":"Ada"} and distinct ID "user_123" | ||
| Then the mock server should receive 1 email send request to "https://us.posthog.com/api/projects/@current/messaging/send/email/" | ||
| And the request header "Authorization" should equal "Bearer phs_test" | ||
| And the request body should equal JSON {"template":{"id":"0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b"},"to":"user@example.com","message_data":{"name":"Ada"},"identifiers":{"distinct_id":"user_123"}} | ||
| And the send result should have ID "msg_1" and replayed false | ||
|
|
||
| # Requirement: Custom host refusal | ||
|
|
||
| Scenario: A custom host without an app host never receives the secret key | ||
| Given the SDK is initialized with token "test-token", host "https://proxy.example.com" and secret key "phs_test" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the send should fail with error code "missing_app_host" | ||
| And the mock server should receive no requests | ||
|
|
||
| # Requirement: Secret key | ||
|
|
||
| Scenario: A client without a secret key sends nothing | ||
| Given the SDK is initialized with token "test-token" and host "https://us.i.posthog.com" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the send should fail with error code "missing_secret_key" | ||
| And the mock server should receive no requests | ||
|
|
||
| # Requirements: Idempotency key, Retryable responses | ||
|
|
||
| Scenario: Retries reuse the generated idempotency key | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will fail the next email send with status 503 | ||
| And the mock server will accept the next email send with message ID "msg_1" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the mock server should receive 2 email send requests | ||
| And both requests should carry the same "Idempotency-Key", which is a UUID v4 | ||
| And the send result should have ID "msg_1" | ||
|
|
||
| Scenario Outline: Only responses that waiting can fix are retried | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will fail the next email send with status <status> and code "<code>" | ||
| And the mock server will accept the next email send with message ID "msg_1" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the mock server should receive <requests> email send requests | ||
|
|
||
| Examples: | ||
| | status | code | requests | | ||
| | 503 | delivery_unavailable | 2 | | ||
| | 429 | throttled | 2 | | ||
| | 409 | idempotency_key_in_flight | 2 | | ||
| | 429 | quota_limited | 1 | | ||
| | 422 | recipient_suppressed | 1 | | ||
|
|
||
| # Requirement: Send response without a message ID | ||
|
|
||
| Scenario: An acceptance without a message ID is an error and is not retried | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will answer the next email send with status 202 and body {} | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the mock server should receive 1 email send request | ||
| And the send should fail with error code "api_error" and status 202 | ||
|
|
||
| # Requirement: Retry timing | ||
|
|
||
| Scenario: A Retry-After over 30 seconds fails at once | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will fail the next email send with status 429, code "throttled" and header "Retry-After: 60" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the mock server should receive 1 email send request | ||
| And the send should fail with error code "throttled" and a retry-after of 60 seconds | ||
|
|
||
| # Requirements: Error fields, Error names | ||
|
|
||
| Scenario Outline: Errors keep PostHog's code and carry a category name | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will fail the next email send with status <status> and code "<code>" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" and a per-send retry limit of 0 | ||
| Then the send should fail with error code "<code>", name "<name>" and status <status> | ||
|
|
||
| Examples: | ||
| | status | code | name | | ||
| | 401 | authentication_failed | missing_api_key | | ||
| | 404 | not_found | not_found | | ||
| | 422 | idempotency_key_reused | invalid_idempotent_request | | ||
| | 422 | recipient_suppressed | validation_error | | ||
| | 429 | quota_limited | monthly_quota_exceeded | | ||
|
|
||
| # Requirements: Status read request, Status record | ||
|
|
||
| Scenario: A status read returns the email's status | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will return the email status {"id":"01a18356-c56c-0001-e494-7666bc915188","status":"bounced","created_at":"2026-10-28T16:27:44.364000Z","status_at":"2026-10-28T16:30:02.100000Z","reason":"hard"} | ||
| When the caller reads the status of message ID "01A18356-C56C-0001-E494-7666BC915188" | ||
| Then the mock server should receive 1 request "GET https://us.posthog.com/api/projects/@current/messaging/emails/01a18356-c56c-0001-e494-7666bc915188/" | ||
| And the status record should have status "bounced", reason "hard" and both times | ||
|
|
||
| Scenario: An unknown message ID is not retried | ||
| Given the SDK is initialized with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| And the mock server will fail the next email status read with status 404 and code "not_found" | ||
| When the caller reads the status of message ID "01a18356-c56c-0001-e494-7666bc915188" | ||
| Then the mock server should receive 1 email status request | ||
| And the status read should fail with error code "not_found" | ||
|
|
||
| # Requirement: Disabled client | ||
|
|
||
| Scenario: A disabled client sends nothing | ||
| Given the SDK is initialized disabled with token "test-token", host "https://us.i.posthog.com" and secret key "phs_test" | ||
| When the caller sends to "user@example.com" with template ID "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b" | ||
| Then the send should fail with error code "client_disabled" | ||
| And the mock server should receive no requests |
2 changes: 2 additions & 0 deletions
2
openspec/changes/archive/2026-10-29-add-emails/.openspec.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-10-29 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| ## Context | ||
|
|
||
| Sources, in priority order: | ||
|
|
||
| 1. The shipped SDKs: posthog-node `packages/node/src/emails/` (`emails.ts`, `errors.ts`, `types.ts`) in PostHog/posthog-js#5418, and posthog-python `posthog/emails.py` in PostHog/posthog-python#1093. | ||
| 2. The backend: PostHog/posthog `products/messaging/backend/presentation/views/message_templates.py` (`MessagingSendViewSet`, the send serializers and `TransactionalEmailRejected`) and `transactional_email_status.py` (`MessagingEmailsViewSet`). | ||
| 3. The shared contract in the PostHog/posthog#120921 comment thread, which named the template form, the idempotency key default, and the per-send retry override. | ||
|
|
||
| ## Goals / Non-Goals | ||
|
|
||
| **Goals:** | ||
|
|
||
| - One contract every server SDK implements for sending a transactional email and reading its status. | ||
| - A retry policy that reuses one idempotency key across a call's attempts, so PostHog can deduplicate retries, and never retries what waiting can't fix. PostHog's deduplication has a known gap: if an email is queued but its key's completion record is lost, an attempt that arrives after the 60 second pending reservation expires can queue it again. | ||
| - Errors that keep PostHog's stable `code`, so a new server code needs no SDK change. | ||
|
|
||
| **Non-Goals:** | ||
|
|
||
| - Client SDKs. The send and status APIs take a secret key, which must stay on a server. | ||
| - The code-content form and `capture_engagement`. The send API does not accept them yet. | ||
|
|
||
| ## Decisions | ||
|
|
||
| - **Error channel follows the language.** posthog-node returns `{ data, error }` and does not reject, which matches its other awaited methods. posthog-python raises `EmailError`. Both are idiomatic, so the spec requires the platform's idiomatic channel and forbids both at once, rather than forcing one shape. | ||
| - **Local argument checks follow the language.** posthog-node returns a `validation_error` with an `invalid_*` code, and posthog-python raises `TypeError` or `ValueError`. The spec requires the check to happen before any request and allows either channel. | ||
| - **Per-send `max_retries` is canonical.** The shared contract named it. posthog-node has it and posthog-python does not, so posthog-python is the SDK to change. | ||
| - **The custom-host refusal is canonical.** Both SDKs refuse to send the secret key to a host that is not PostHog Cloud unless the caller names the app host, because a custom host is often an ingestion proxy. | ||
| - **The status record keeps native types.** posthog-python returns datetimes and posthog-node returns ISO 8601 strings. The spec requires the timestamps and allows either representation. | ||
|
|
||
| ## Conformance at the time of writing | ||
|
|
||
| Assessed at posthog-node `eb01f45` (PostHog/posthog-js#5418) and posthog-python `5abcf29` (PostHog/posthog-python#1093). | ||
|
|
||
| | Requirement | posthog-node | posthog-python | | ||
| | --- | --- | --- | | ||
| | Public surface | Pass | Pass | | ||
| | Per-send `max_retries` | Pass | Pass, from `5abcf29`. Earlier revisions had no per-send override | | ||
| | Shutdown | Pass | Partial: async shutdown waits for email requests in flight. Synchronous shutdown closes the session without waiting for a send or a retry running on another thread | | ||
| | All other requirements | Pass | Pass | | ||
|
|
||
| ## Risks / Trade-offs | ||
|
|
||
| - Both SDKs had not published when this was written. The contract describes the merged code in the two PRs, and a change before release means a delta here. | ||
21 changes: 21 additions & 0 deletions
21
openspec/changes/archive/2026-10-29-add-emails/proposal.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| ## Why | ||
|
|
||
| Transactional email ships in posthog-node (`posthog.emails`) and posthog-python (`client.emails`, `Emails`, `AsyncEmails`). The two SDKs share a contract that so far lives only in a comment on PostHog/posthog#120921. They agree on the request, the retry policy and the error codes, but they differ in how they report an error, in local argument checks, and in per-send retry overrides. Other server SDKs have no contract to implement against except these two implementations, and the weekly compliance audit does not track email at all. | ||
|
|
||
| This change records one contract for sending a transactional email from a template and reading its status, before more SDKs add it. | ||
|
|
||
| ## What Changes | ||
|
|
||
| - New capability spec `emails` for server SDKs, covering: the public surface (`send` and `get`), authentication with a secret key, the app host and the refusal to send the key to a custom host, the send request, the idempotency key, the retry policy, the send result and its warnings, structured errors with PostHog's code and a category name, the status read, disabled and shut-down clients, and shutdown draining. | ||
| - New acceptance feature `acceptance/public/emails.feature`. | ||
| - Not in scope: the code-content form (subject and body in the call), the `capture_engagement` send option, and client SDKs. The send API does not accept the first two yet, and a secret key must never reach a client. | ||
|
|
||
| ## Capabilities | ||
|
|
||
| ### New Capabilities | ||
|
|
||
| - `emails`: server-side transactional email. Sends one email from a transactional template to one recipient, with a stable idempotency key across retries, and reads the email's delivery status by its message ID. | ||
|
|
||
| ### Modified Capabilities | ||
|
|
||
| None. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.