Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 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
8041389
feat(emails): send cc, bcc and attachments with emails()->send()
Silthus Nov 4, 2026
8efe315
fix(emails): check email options when the client is built, and leave …
Silthus Nov 4, 2026
7cefa76
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
b2a1c5c
fix(emails): refuse a non-integer email option, and say the reasons t…
Silthus Nov 4, 2026
9f3bc01
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
14ec0dc
fix(emails): a null attachment option counts as left out, and non-UTF…
Silthus Nov 4, 2026
86447e6
fix(emails): drop the remaining private and test comments
Silthus Nov 4, 2026
faee0cf
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
39aaf25
feat(emails): name the sending integration with an integration option
Silthus Nov 5, 2026
68a32d9
Merge remote-tracking branch 'origin/main' into feat/emails-cc-bcc-at…
Silthus Nov 6, 2026
335dc37
Merge remote-tracking branch 'origin/main' into feat/emails-cc-bcc-at…
Silthus Nov 9, 2026
eba0556
feat(emails): upload attachments by hash, then send references
Silthus Nov 9, 2026
72a3174
fix(emails): end an upload refusal with one period
Silthus Nov 9, 2026
d87ef8b
refactor(emails): name the attachment in an upload refusal before bui…
Silthus Nov 9, 2026
7a720ed
fix(emails): stop uploads when the client shuts down, honor storage R…
Silthus Nov 9, 2026
55089ad
fix(emails): retry an upload after a passing storage fault
Silthus Nov 9, 2026
b7affe7
fix(emails): name a storage 401 as an upload failure, guard the send …
Silthus Nov 9, 2026
2d96d4d
fix(emails): keep the connect timeout at the request timeout for larg…
Silthus Nov 9, 2026
ff26044
docs(emails): name the storage statuses an upload retries
Silthus Nov 9, 2026
2ff3d91
fix(emails): report PostHog's own answer when the upload route is not…
Silthus Nov 9, 2026
5934ddf
refactor(emails): drop two inline comments the commit messages alread…
Silthus Nov 9, 2026
44987c2
fix(emails): count the subject in the size estimate, and test uploads…
Silthus Nov 10, 2026
1d9e602
test(emails): run the connect timeout test only where connecting stalls
Silthus Nov 10, 2026
147e4c7
test(emails): pin the subject term of the size estimate to node-store…
Silthus Nov 10, 2026
694a980
test(emails): drop two tests that repeat another test's input
Silthus Nov 10, 2026
f257fe0
test(emails): make the HTTP storage fixture refuse bytes that miss th…
Silthus Nov 10, 2026
90ca165
test(emails): assert upload outcomes against storage that enforces it…
Silthus Nov 10, 2026
f7dd93f
test(emails): prove storage keeps no form label on an uploaded file
Silthus Nov 10, 2026
379fa80
test(emails): refuse a send whose files storage doesn't hold, instead…
Silthus Nov 10, 2026
7bfc98b
fix(emails): drop the blank Content-Type on uploads; storage fakes re…
Silthus Nov 10, 2026
1041d8b
fix(emails): count the derived text and upload only over https
Silthus Nov 10, 2026
62bcc89
fix(emails): name a storage 429 a rate limit; assert outcomes, not re…
Silthus Nov 10, 2026
27df80d
test(emails): fakes negotiate uploads; assert stored files and queued…
Silthus Nov 10, 2026
569df7a
fix(emails): never follow an upload's redirect; name upload failures …
Silthus Nov 10, 2026
9ee38b1
Merge remote-tracking branch 'origin/feat/emails-cc-bcc-attachments' …
Silthus Nov 13, 2026
58d2a5a
Merge remote-tracking branch 'origin/main' into feat/emails-integrati…
Silthus Nov 13, 2026
ec29f52
test(emails): an invalid integration on a send with files uploads not…
Silthus Nov 13, 2026
db80d20
Merge main into feat/emails-cc-bcc-attachments
Silthus Nov 13, 2026
25bcacd
Merge branch 'feat/emails-cc-bcc-attachments' into feat/emails-integr…
Silthus Nov 13, 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-cc-bcc-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": minor
---

Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. Files upload only over https, unless the `app_host` is plain `http://` for local development, and an upload never follows a redirect. A failed upload throws `attachment_upload_failed`.
5 changes: 5 additions & 0 deletions .changeset/emails-integration-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": minor
---

Transactional email: `emails()->send()` takes an `integration` option, `<name>/<version>` such as `laravel/1.4.0`, that a mailer integration passes to name itself on the send. The client sends it as the `X-PostHog-Integration` header; the User-Agent stays the same.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,23 @@ PostHog::emails()->send([
'distinct_id' => 'user_123',
]);

// With copies and a file: content is the file's bytes, and the SDK uploads them before the send
PostHog::emails()->send([
'to' => 'user@example.com',
'template_key' => 'receipt',
'subject' => 'Your receipt',
'html' => $html,
'bcc' => 'finance@example.com',
'attachments' => [['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)]],
]);

// 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, ...
```

- **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`, `sensitive` and `tracking`. 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.
- **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`, `cc`, `bcc`, `attachments`, `sensitive` and `tracking`. 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.
- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and without `text` it counts the `html` twice, since PostHog sends the text it derives from it. The SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `<img src="cid:logo">` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it.
- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. Files go only to an `https` storage URL, unless your `app_host` is plain `http://` for local development. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `attachment_upload_failed` with the storage's answer, and no email is sent. An upload never follows a redirect: storage that answers one fails that file the same way, without naming the URL.
- **Sensitive emails.** Pass `'sensitive' => true` when the email carries login links, reset tokens or codes. A sensitive email keeps its links unchanged unless `tracking` turns clicks on, leaves its subject off engagement events, and keeps its content out of logs. Left out, the email takes the setting of the template made in PostHog whose key matches `template_key`, and is sensitive when no template matches.
- **Tracking.** Left out, PostHog counts the email's opens and captures its engagement events, and tracks clicks only when the email isn't sensitive. `'tracking' => true` turns on opens, clicks and engagement events. `'tracking' => false` turns them all off: no open pixel, no rewritten links, no engagement events. An array such as `['clicks' => true]` sets `opens` (default `true`), `clicks` (default: on unless the email is sensitive) and `engagement` (default `true`) one by one. Turn clicks on for a sensitive email only when its links are safe to rewrite. Click tracking needs open tracking. `capture_engagement` is deprecated in favor of `tracking` with `engagement`. It still works, but a send can't set both.
- **Pass `distinct_id`.** Without it, the email's engagement events go to a person whose distinct ID is the email address.
Expand Down
16 changes: 14 additions & 2 deletions lib/EmailError.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
*/
class EmailError extends RuntimeException
{
private const REFUSED_ATTACHMENT_CODES = ['too_many_attachments', 'message_too_large'];

private string $errorName;
private string $errorCode;
private ?int $statusCode;
Expand Down Expand Up @@ -68,8 +70,8 @@ public function getErrorName(): string

/**
* PostHog's error code, such as `quota_limited`, or an SDK code such as `missing_secret_key`,
* `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` or an
* `invalid_*` argument code.
* `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error`, `attachment_upload_failed`
* or an `invalid_*` argument code.
*/
public function getErrorCode(): string
{
Expand Down Expand Up @@ -126,9 +128,19 @@ public function getRetryAfterMs(): ?int

private static function nameFor(?int $status, string $code): string
{
if ($code === 'attachment_upload_failed') {
return match (true) {
$status === 429 => 'rate_limit_exceeded',
$status !== null && $status >= 500 => 'service_unavailable',
default => 'application_error',
};
}
if ($code === 'missing_secret_key' || $status === 401) {
return 'missing_api_key';
}
if (in_array($code, self::REFUSED_ATTACHMENT_CODES, true)) {
return 'validation_error';
}
if ($status === null) {
if ($code === 'network_error') {
return 'service_unavailable';
Expand Down
Loading
Loading