Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ capability-driven selection.
| Capture Exception | [`openspec/specs/capture-exception/spec.md`](openspec/specs/capture-exception/spec.md) | public API | Canonical |
| Create Person Profile | [`openspec/specs/create-person-profile/spec.md`](openspec/specs/create-person-profile/spec.md) | public API | Canonical |
| Debug | [`openspec/specs/debug/spec.md`](openspec/specs/debug/spec.md) | public API | Canonical |
| Emails | [`openspec/specs/emails/spec.md`](openspec/specs/emails/spec.md) | public API (server) | Canonical |
| Exception Steps | [`openspec/specs/exception-steps/spec.md`](openspec/specs/exception-steps/spec.md) | public API | Canonical |
| Flush | [`openspec/specs/flush/spec.md`](openspec/specs/flush/spec.md) | public API | Canonical |
| Get Anonymous ID | [`openspec/specs/get-anonymous-id/spec.md`](openspec/specs/get-anonymous-id/spec.md) | public API | Canonical |
Expand Down
119 changes: 119 additions & 0 deletions acceptance/public/emails.feature
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-29
43 changes: 43 additions & 0 deletions openspec/changes/archive/2026-10-29-add-emails/design.md
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 |
Comment thread
mayteio marked this conversation as resolved.
Resolved

## 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 openspec/changes/archive/2026-10-29-add-emails/proposal.md
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.
Loading
Loading