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
5 changes: 5 additions & 0 deletions .changeset/emails-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": minor
---

A send with your own content (`template_key`) takes `sensitive` and `tracking`. Set `sensitive` to true when the email carries login links, reset tokens or codes: PostHog then 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` chooses what PostHog tracks for the email. Left out, PostHog counts opens and captures engagement events, and tracks clicks only when the email isn't sensitive. `true` turns on opens, clicks and engagement events, and `false` turns them all off. An array such as `['clicks' => true]` sets `opens`, `clicks` and `engagement` one by one. `capture_engagement` is deprecated in favor of `tracking` with `engagement`; it still works, but a send can't set both. The client now sends it only when you set it; PostHog already reads a missing value as on. The client sends `sensitive` and `tracking` only when you set them. They need PostHog's API support for the fields, which is rolling out.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ PostHog::emails()->send([
$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` 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.
- **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.
- **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.
- **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.
Expand Down
84 changes: 76 additions & 8 deletions lib/Emails.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,19 @@ final class Emails

private const SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries'];
private const TEMPLATE_FIELDS = ['template_id', 'template_data'];
private const CODE_FIELDS = ['template_key', 'subject', 'html', 'text', 'from', 'reply_to', 'capture_engagement'];
private const TEMPLATE_FORM_WHY = "A template made in PostHog sets its own sender, subject and content, "
. "and follows the project's engagement setting.";
private const CODE_FIELDS = [
'template_key',
'subject',
'html',
'text',
'from',
'reply_to',
'capture_engagement',
'sensitive',
'tracking',
];
private const TEMPLATE_FORM_WHY = "A template made in PostHog sets its own sender, subject, content, tracking "
. "and sensitive setting, and follows the project's engagement setting.";
private const CODE_FORM_WHY = 'A send with template_key takes its content from your code.';

private const SEND_NOT_DONE = 'no email was sent';
Expand Down Expand Up @@ -133,8 +143,28 @@ public function __construct(
* 400), `subject`, and `html`, `text` or both. PostHog derives a missing `text` from the `html`, and
* sends the content as is without storing it. To send a React email, render it to HTML in your app
* first. This form also takes `from` (a verified sender, such as `Acme <hello@example.com>`),
* `reply_to` (one address or a list; an empty list is left out) and `capture_engagement` (default
* true; a project whose admin turned engagement capture off captures nothing, whatever the send asks).
* `reply_to` (one address or a list; an empty list is left out), `sensitive` and `tracking`.
*
* `sensitive` marks the email as sensitive: set it to 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` says what PostHog tracks for the email. Left out, PostHog counts opens and captures
* engagement events, and tracks clicks only when the email isn't sensitive. `true` turns on opens,
* clicks and engagement events, even for a sensitive email. `false` turns them all off: the email
* has no open pixel and no rewritten links, and PostHog captures no engagement events for it. 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. A project whose
* admin turned engagement capture off captures nothing, whatever the send says.
*
* The client sends `sensitive` and `tracking` only when you set them, and they need a PostHog
* version that takes them.
*
* `capture_engagement` is deprecated: use `tracking` with `engagement` instead. It still works, but a
* send can't set both. The client sends `capture_engagement` only when you set it.
*
* Both forms take `to` (one address), `distinct_id` (the recipient's distinct ID in your product;
* without it, the email's events go to a person whose distinct ID is the address), `idempotency_key`
Expand Down Expand Up @@ -176,6 +206,8 @@ public function __construct(
* text?: string|null,
* from?: string|null,
* reply_to?: string|list<string>|null,
* sensitive?: bool|null,
* tracking?: bool|array{opens?: bool|null, clicks?: bool|null, engagement?: bool|null}|null,
* capture_engagement?: bool|null
* } $email
* @return SendEmailResult The queued email. It means PostHog queued the email, not that it was delivered.
Expand Down Expand Up @@ -356,15 +388,51 @@ private static function codeContent(array $email): array
if (array_key_exists('reply_to', $email) && $email['reply_to'] !== []) {
$content['reply_to'] = self::addresses($email['reply_to']);
}
$captureEngagement = $email['capture_engagement'] ?? true;
if (!is_bool($captureEngagement)) {
$tracking = self::tracking($email);
$captureEngagement = $email['capture_engagement'] ?? null;
if ($captureEngagement !== null && !is_bool($captureEngagement)) {
throw self::invalid('invalid_form', 'capture_engagement must be true or false.', 'capture_engagement');
}
$content['capture_engagement'] = $captureEngagement;
if ($captureEngagement !== null) {
$content['capture_engagement'] = $captureEngagement;
}
$sensitive = $email['sensitive'] ?? null;
if ($sensitive !== null && !is_bool($sensitive)) {
throw self::invalid('invalid_form', 'sensitive must be true or false.', 'sensitive');
}
if ($sensitive !== null) {
$content['sensitive'] = $sensitive;
Comment thread
Silthus marked this conversation as resolved.
Resolved
}
if ($tracking !== null) {
$content['tracking'] = $tracking;
Comment thread
Silthus marked this conversation as resolved.
Resolved
}

return $content;
}

private static function tracking(array $email): bool|object|null
{
$tracking = $email['tracking'] ?? null;
if ($tracking === null || is_bool($tracking)) {
return $tracking;
}
if (!is_array($tracking) || ($tracking !== [] && array_is_list($tracking))) {
throw self::invalid(
'invalid_form',
"tracking must be true, false or an array such as ['clicks' => true].",
'tracking'
);
}
$tracking = array_filter($tracking, static fn($value): bool => $value !== null);
foreach ($tracking as $kind => $on) {
if (!is_bool($on)) {
throw self::invalid('invalid_form', "tracking['$kind'] must be true or false.", 'tracking');
}
}

return (object) $tracking;
}

/**
* @return list<string>
*/
Expand Down
1 change: 0 additions & 1 deletion test/EmailsClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ public function testACodeSendReachesTheSendRouteOverHttp(): void
'template_key' => 'welcome',
'subject' => 'Welcome',
'html' => $html,
'capture_engagement' => true,
'to' => 'user@example.com',
'distinct_id' => 'user_123',
], json_decode($requests[0]['body'], true));
Expand Down
52 changes: 50 additions & 2 deletions test/EmailsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ public function testCodeSendPostsRenderedContent(): void
'text' => 'Reset it here: https://example.com/r',
'from' => 'Acme <security@example.com>',
'reply_to' => ['help@example.com'],
'capture_engagement' => true,
'to' => 'user@example.com',
'distinct_id' => 'user_123',
], $this->body());
Expand All @@ -205,6 +204,49 @@ public function testCodeSendKeepsAReplyToListAndCaptureEngagementOff(): void
], $this->body());
}

#[DataProvider('trackingCases')]
public function testCodeSendPassesSensitiveAndTrackingOnlyWhenSet(array $options, string $expected): void
{
$this->emails()->send([
'to' => 'user@example.com',
'template_key' => 'welcome',
'subject' => 'Welcome',
'html' => '<p>Hi</p>',
] + $options);

$this->assertSame(
'{"template_key":"welcome","subject":"Welcome","html":"<p>Hi</p>",' . $expected
. '"to":"user@example.com"}',
$this->requests[0]['body']
);
}

public static function trackingCases(): array
{
return [
'unset' => [['tracking' => null], ''],
'true' => [['tracking' => true], '"tracking":true,'],
'false' => [['tracking' => false], '"tracking":false,'],
'clicks on' => [['tracking' => ['clicks' => true]], '"tracking":{"clicks":true},'],
'null kinds left out' => [
['tracking' => ['opens' => false, 'clicks' => null, 'engagement' => false]],
'"tracking":{"opens":false,"engagement":false},',
],
'empty array' => [['tracking' => []], '"tracking":{},'],
'with capture_engagement' => [
['tracking' => true, 'capture_engagement' => false],
'"capture_engagement":false,"tracking":true,',
],
'sensitive unset' => [['sensitive' => null], ''],
'sensitive' => [['sensitive' => true], '"sensitive":true,'],
'not sensitive' => [['sensitive' => false], '"sensitive":false,'],
'not sensitive with clicks off' => [
['sensitive' => false, 'tracking' => ['clicks' => false]],
'"sensitive":false,"tracking":{"clicks":false},',
],
];
}

public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void
{
$this->emails()->send([
Expand All @@ -216,6 +258,7 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void
'from' => null,
'reply_to' => [],
'capture_engagement' => null,
'sensitive' => null,
'distinct_id' => null,
'idempotency_key' => null,
'template_id' => null,
Expand All @@ -225,7 +268,6 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void
'template_key' => 'receipt',
'subject' => 'Your receipt',
'html' => '<p>Thanks!</p>',
'capture_engagement' => true,
'to' => 'user@example.com',
], $this->body());
}
Expand Down Expand Up @@ -327,6 +369,8 @@ public static function argumentErrors(): array
'invalid_form',
'capture_engagement',
],
'tracking with a template' => [$template + ['tracking' => false], 'invalid_form', 'tracking'],
'sensitive with a template' => [$template + ['sensitive' => true], 'invalid_form', 'sensitive'],
'unknown option' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'],
'template_data with code' => [$code + ['template_data' => ['a' => 1]], 'invalid_form', 'template_data'],
'bad template key' => [['template_key' => 'pass word'] + $code, 'invalid_template_key', 'template_key'],
Expand All @@ -347,6 +391,10 @@ public static function argumentErrors(): array
'invalid_form',
'capture_engagement',
],
'tracking not a bool or an array' => [$code + ['tracking' => 'no'], 'invalid_form', 'tracking'],
'tracking a list' => [$code + ['tracking' => [true]], 'invalid_form', 'tracking'],
'tracking kind not a bool' => [$code + ['tracking' => ['clicks' => 'yes']], 'invalid_form', 'tracking'],
'sensitive not a bool' => [$code + ['sensitive' => 'yes'], 'invalid_form', 'sensitive'],
'idempotency key with a space' => [
$template + ['idempotency_key' => 'welcome user'],
'invalid_idempotency_key',
Expand Down
Loading