Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
5 changes: 5 additions & 0 deletions .changeset/emails-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": minor
---

Add transactional email: `$client->emails()->send()` and `PostHog::emails()->send()` queue one email from a template made in PostHog (`template_id`, `template_data`) or from content your code rendered (`template_key`, `subject`, `html` and/or `text`), and `emails()->get()` reads its status. Sending needs a project secret API key (`phs_...`) as `$secretKey`. New client options: `app_host`, `email_request_timeout_ms`, `email_request_max_retries` and `migrated_from`. A failed send throws `PostHog\EmailError`.
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`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. 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