Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d06b4cf
feat: send transactional email with emails()->send()
Silthus Nov 2, 2026
45341fa
fix: resolve the email User-Agent default inside the constructor
Silthus Nov 2, 2026
6a732b9
docs: say a status read answers not_found right after a send
Silthus Nov 2, 2026
181ed05
docs: say flag loading still sends the secret key to host
Silthus Nov 2, 2026
d5e5a2c
docs: show the proxy setup that keeps the secret key off host
Silthus Nov 2, 2026
b870c10
fix: send no retry after the client shut down during the wait
Silthus Nov 2, 2026
15126f7
docs: qualify the Client class in the proxy example
Silthus Nov 2, 2026
c989580
fix: wait out the full retry delay when a signal cuts the sleep short
Silthus Nov 2, 2026
6dbca5a
fix: keep the retry deadline on a monotonic clock
Silthus Nov 2, 2026
03090d8
test: the retry wait follows the monotonic timer when the wall clock …
Silthus Nov 2, 2026
c034daa
fix: throw an EmailError when the curl extension is missing
Silthus Nov 2, 2026
df1d635
test: keep the no-curl test within the line limit
Silthus Nov 2, 2026
d96fa5d
fix: read an HTTP-date Retry-After in UTC
Silthus Nov 3, 2026
84e9c9a
fix: stop reading an email reply over 1 MiB
Silthus Nov 3, 2026
6abff5e
fix: clamp a numeric Retry-After before converting it to milliseconds
Silthus Nov 3, 2026
4676fe0
fix(emails): keep a huge Retry-After inside an int on 32-bit PHP
Silthus Nov 3, 2026
6c395aa
fix(emails): keep retry timing inside an int on 32-bit PHP
Silthus Nov 3, 2026
00abb30
fix(emails): escape control characters in logged send warnings
Silthus Nov 3, 2026
c0cfc6b
fix(emails): skip the retry wait when the client closed during the re…
Silthus Nov 3, 2026
a123195
fix(emails): clamp a Retry-After too long for a float instead of drop…
Silthus Nov 3, 2026
8efe315
fix(emails): check email options when the client is built, and leave …
Silthus Nov 4, 2026
b2a1c5c
fix(emails): refuse a non-integer email option, and say the reasons t…
Silthus Nov 4, 2026
86447e6
fix(emails): drop the remaining private and test comments
Silthus Nov 4, 2026
aa6a0a4
fix(emails): name the transactional_email scopes for sends and status…
Silthus Nov 5, 2026
ccc3a54
docs(emails): rewrite the release note for transactional email
Silthus Nov 5, 2026
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
30 changes: 30 additions & 0 deletions .changeset/emails-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"posthog-php": minor
---

The new `emails()` sends transactional email from your PHP code and reads each email's delivery status.

```php
PostHog::init('phc_...', ['host' => 'https://us.i.posthog.com'], secretKey: 'phs_...');

$result = PostHog::emails()->send([
'to' => 'user@example.com',
'distinct_id' => 'user_123',
'template_key' => 'password-reset',
'subject' => 'Reset your password',
'html' => $renderedHtml,
]);

// 'delivered', 'bounced', ...
$status = PostHog::emails()->get($result->getId())->getStatus();
```

You can also send a template made in PostHog by its `template_id`.

You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secretKey`. Set the `app_host` option to your PostHog app URL when you self-host PostHog or send through a reverse proxy. The client also loads feature flags with this key when it starts, so give it **Feature flags: Read** too, or it logs an error each time a client starts.

PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too.

A send with your own content also takes `from`, `reply_to` and `'capture_engagement' => false`. Set the `migrated_from` option to tell PostHog which email provider you moved from. A failed call throws a `PostHog\EmailError`. Its `getErrorCode()` returns PostHog's code, and `getStatusCode()` returns the HTTP status.

Docs: [Send transactional emails with PHP](https://posthog.com/docs/workflows/transactional-emails/php) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,52 @@ The registered processor also enables request span enrichment when `withContext(

Existing span attributes take precedence. The processor preserves the application's provider, sampling, and exporters. See the [PHP tracing guide](https://posthog.com/docs/distributed-tracing/installation/php) and [Laravel guide](https://posthog.com/docs/libraries/laravel) for setup.

## Transactional email

`emails()` sends one email to one recipient, from an email template made transactional in PostHog or from content your code rendered, and reads what happened to it.

The client needs a project secret API key (`phs_...`) as `secretKey`. Create it with the "Send transactional email" preset (`transactional_email:write`) in your project settings, and add `transactional_email:read` if you read statuses with a key that can't send. Keys made with `hog_flow:write` still work. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`:

```php
$client = new \PostHog\Client('phc_...', [
'host' => 'ph.example.com', // your proxy
'app_host' => 'https://eu.posthog.com',
], loadFeatureFlags: false, secretKey: 'phs_...');
PostHog::init(client: $client);
```

```php
PostHog::init('phc_...', ['host' => 'https://eu.i.posthog.com'], secretKey: 'phs_...');

// A template made in PostHog, by its ID
$result = PostHog::emails()->send([
'to' => 'user@example.com',
'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b',
'template_data' => ['name' => 'Ada'], // fills {{ variables.name }}
'distinct_id' => 'user_123',
'idempotency_key' => 'welcome:user_123',
]);

// Content your code rendered, named by a key
PostHog::emails()->send([
'to' => 'user@example.com',
'template_key' => 'password-reset',
'subject' => 'Reset your password',
'html' => $html,
'distinct_id' => 'user_123',
]);

// Later, such as in a job: for a few seconds after the send, get() throws not_found
$status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, bounced, ...
Comment thread
Silthus marked this conversation as resolved.
Resolved
```

- **Two forms.** Pass `template_id` with optional `template_data`, or `template_key` with `subject` and `html`, `text` or both. The content form also takes `from` (a verified sender), `reply_to` and `capture_engagement` (default `true`). PostHog derives a missing `text` from the `html`. To send an email built with a template library such as React Email, render it to HTML in your app and pass `html`. The SDK adds no rendering dependency.
- **Pass `distinct_id`.** Without it, the email's engagement events go to a person whose distinct ID is the email address.
- **A result means queued**, not delivered. `send()` returns a `SendEmailResult` with the message ID. `get()` reads the status for 30 days after the send. For a few seconds after the send, it throws `not_found`.
- **Errors.** A send that is not queued throws `PostHog\EmailError`. Branch on `getErrorCode()`, such as `recipient_suppressed` or `missing_app_host`, or on `getErrorName()`, such as `validation_error`. An invalid argument throws before any request, with an `invalid_*` code.
- **Retries.** Network errors, rate limits and an unavailable service are retried up to `email_request_max_retries` times (default 2) with the same idempotency key, so a retry never sends twice. Each request times out after `email_request_timeout_ms` (default 15000). When a job runner already retries the send, pass `'max_retries' => 0` and an `idempotency_key` derived from what the email is for, such as `welcome:user_123`. A generated key does not survive a job retry.
- **Moving from another provider.** Set the `migrated_from` option, such as `'resend'`, so PostHog knows where your sends came from.

## Questions?

### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
Expand Down
Loading
Loading