diff --git a/contents/docs/workflows/transactional-emails/api.mdx b/contents/docs/workflows/transactional-emails/api.mdx new file mode 100644 index 000000000000..9f88ac03256b --- /dev/null +++ b/contents/docs/workflows/transactional-emails/api.mdx @@ -0,0 +1,112 @@ +--- +title: Send transactional emails with the API +sidebar: Docs +showTitle: true +--- + +Use the API to send transactional emails from any language. For Node.js and Python, the [Node.js SDK](/docs/workflows/transactional-emails/node) and the [Python SDK](/docs/workflows/transactional-emails/python) handle retries and idempotency keys for you. + +Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Send an email + +``` +POST https://us.posthog.com/api/projects/@current/messaging/send/email/ +``` + +- **Host:** Use `https://us.posthog.com` or `https://eu.posthog.com`, the host where you log in to PostHog. Don't use an ingestion host like `us.i.posthog.com`. If you self-host PostHog, use the URL of your instance. +- **Project:** `@current` is the project that the key belongs to. You can use your project ID instead. + +### Request + +Send these headers: + +| Header | Value | +| --- | --- | +| `Authorization` | `Bearer` followed by your project secret API key | +| `Content-Type` | `application/json` | +| `Idempotency-Key` | Optional. A unique key for this email. See [retry safely](/docs/workflows/transactional-emails#retry-safely-with-an-idempotency-key). | + +Send a JSON body with these fields: + +| Field | Required | Description | +| --- | --- | --- | +| `template_id` | Yes | The ID of the transactional template to send. | +| `to` | Yes | The email address of the recipient. One address for each request. | +| `message_data` | No | An object with the values for the template's `{{ variables.* }}` placeholders. For example, `{"reset_link": "https://example.com/reset"}` fills `{{ variables.reset_link }}`. The template can't read person properties, so pass all the data that the email needs here. | +| `distinct_id` | No | The distinct ID of the recipient's person. PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. If you leave it out, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it. | + +In the template, use the same names that you send in `message_data`: + +```liquid +Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }} +``` + +### Response + +A successful request returns `202 Accepted`: + +```json +{ + "message_id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "replayed": false +} +``` + +A `202` means that PostHog queued the email. It doesn't mean that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status) with its `message_id`, or open the template's **Sends** tab. + +`replayed` is `true` when an earlier request with the same `Idempotency-Key` already queued this email. In that case, PostHog sent no new email, and `message_id` is the ID of the first send. + +### Example + +```bash +curl -X POST "https://us.posthog.com/api/projects/@current/messaging/send/email/" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: password-reset-8f14e45f" \ + -d '{ + "template_id": "", + "to": "jane@example.com", + "distinct_id": "user_123", + "message_data": { "first_name": "Jane", "reset_link": "https://example.com/reset?token=abc123" } + }' +``` + +The older route, `POST /api/projects/@current/messaging_templates/:template_id/send/`, still works. It takes the template ID in the URL and the distinct ID as `identifiers.distinct_id`. + +When a send fails, the response carries a `code`. See [errors](/docs/workflows/transactional-emails#errors). After a `503`, PostHog might have queued the email. Retry with the same `Idempotency-Key` rather than sending the email through another provider, or the recipient can get it twice. + +## Check an email's status + +To find out what happened to an email, read its status with the `message_id` from the send: + +``` +GET https://us.posthog.com/api/projects/@current/messaging/emails/:message_id/ +``` + +Send the same `Authorization` header as for a send. The key needs the `hog_flow:read` or `hog_flow:write` scope. Only a project secret API key can read a status. + +A found email returns `200`: + +```json +{ + "id": "01963d8a-6f2c-7d4e-9b1a-3c5e8f2a7b10", + "status": "delivered", + "created_at": "2026-10-28T16:27:44.364000Z", + "status_at": "2026-10-28T16:27:49.625000Z", + "reason": null +} +``` + +See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status) for what each value means. + +A `404` with the code `not_found` means the status isn't there yet, is older than 30 days, or the ID is wrong. See [when a status isn't found](/docs/workflows/transactional-emails#when-a-status-isnt-found). It returns `401` when the request has no project secret API key, `403` when the key doesn't have a Workflow scope, and `429` when you read too often. + +Each project secret API key can read 600 statuses per minute and 20,000 per hour. All the keys of one project share the same limits in total. Reads have their own limits, so they don't use up the limits for sends. + +### Example + +```bash +curl "https://us.posthog.com/api/projects/@current/messaging/emails//" \ + -H "Authorization: Bearer $POSTHOG_PROJECT_SECRET_API_KEY" +``` diff --git a/contents/docs/workflows/transactional-emails/index.mdx b/contents/docs/workflows/transactional-emails/index.mdx new file mode 100644 index 000000000000..59db8c114a73 --- /dev/null +++ b/contents/docs/workflows/transactional-emails/index.mdx @@ -0,0 +1,190 @@ +--- +title: Send transactional emails +sidebar: Docs +showTitle: true +--- + + + +Transactional emails are a new feature. Things might change or break while we work on them. + + + +Transactional emails are one-off emails that a person triggers in your product, like password resets, sign-in codes, and receipts. You write the email once in PostHog. Your backend then sends it to one recipient with one API call. + +**Quick start:** [Node.js](/docs/workflows/transactional-emails/node) · [Python](/docs/workflows/transactional-emails/python) · [API](/docs/workflows/transactional-emails/api) + +## Create a transactional email + +1. Open [**Transactional**](https://app.posthog.com/transactional) in PostHog and click **New transactional email**. +2. **Name:** Name the email, and turn on [**Protect sensitive data**](#protect-sensitive-data) if it carries sign-in codes or reset links. +3. **Sender:** Choose the verified sender the email comes from. To set up a sender, see [configure a channel](/docs/workflows/configure-channels). +4. **Content:** Write the subject and the body. Add the variables your code sends, such as `reset_link`, and use them as `{{ variables.reset_link }}`. +5. **Send from your code:** Copy the example with the email's template ID. + +Each transactional email has its own page in **Transactional**, with these tabs: + +- **Design** edits the email and its sender. +- **Metrics** shows the metrics for its sends. +- **Sends** lists each send, with its log messages. +- **Versions** keeps each version that you publish. +- **API** shows the template ID and an example send. + + + +Transactional emails ignore marketing opt-outs, so use them only for emails that a person needs, never for marketing. A transactional email can't be changed back to a regular template. + + + +### Use an existing template + +To send an email template you already have, open it in the **Templates** tab, open its **API** tab, and click **Make transactional**. The template needs a subject and a body, a verified sender, and no marketing [message category](/docs/workflows/opt-outs#message-categories). If you still use the template for other emails, click **Duplicate** in its menu first, and make the copy transactional. + +### Set it up with an agent + +With the [PostHog MCP server](/docs/model-context-protocol) in your coding agent, ask the agent to set up a transactional email. It can design the email from your brand, make it transactional, and write the code that sends it. It asks you to confirm before it makes a template transactional, and you create the API key yourself. + +You can also make a template transactional with the API: call `POST /api/projects/:project_id/messaging_templates/:template_id/make_transactional/` with a [personal API key](/docs/api/personal-api-keys) that has the `hog_flow:write` scope. + +### Edit a transactional email + +- **Sender:** The **Design** tab shows who the email comes from. Click **Edit sender** to choose a different verified sender, or to set a display name, a from address, or a reply-to address. The from address must be on the sender's verified domain. To use more than one reply-to address, separate them with commas. +- **Drafts:** Your edits save as a draft. Sends use the live version of the email until you click **Publish**. Each publish adds a version on the **Versions** tab. To remove your edits, use **Discard draft**. +- **Checks:** PostHog doesn't publish a change that would stop the email from sending, such as an empty subject or body, or a marketing category. + +### Protect sensitive data + +For emails with sign-in codes, reset links, or other secrets, turn on **Protect sensitive data**, in the first step of a new email or on its **API** tab. PostHog then keeps no copy of what each email contained, and doesn't track link clicks, so a code or a token in a link never goes through a tracking redirect. Deliveries, opens, bounces, and the email's status are still tracked. You can turn this setting on or off at any time. + +## Create an API key + +Send transactional emails with a [project secret API key](/docs/api/project-secret-api-keys) that has the `hog_flow:write` scope. The same key can also [read the status](#check-an-emails-status) of each email it sends. + +1. Go to [**Project secret API keys**](https://app.posthog.com/settings/project-secret-api-keys) in your project settings. +2. Click **Create project secret API key** and give the key a label. +3. In the scopes list, set **Workflow** to **Write**. +4. Copy the key. It starts with `phs_`, and you can't see it again after you leave the page. + +A project secret API key with this scope can send transactional emails and read their status, and nothing else. It can't read or change your templates, and it can't use any other part of the Workflows API. Keep the key on your server. Never put it in client-side code. + +To give a service a key that can read statuses but not send, set **Workflow** to **Read** instead. + +## Send an email + +Your backend sends each email with one call. A send needs: + +- **The template ID.** The template's **API** tab shows it. +- **The recipient.** One email address for each send. +- **The template data.** The values for the template's `{{ variables.* }}` placeholders. The template can't read person properties, so pass all the data that the email needs. +- **The recipient's distinct ID.** PostHog records the send and its [engagement events](/docs/workflows/engagement-events) against this person. Without it, PostHog uses the email address as the distinct ID, which might not match the person in your product. We recommend that you always send it. +- **An idempotency key.** It makes a retry safe. See [retry safely](#retry-safely-with-an-idempotency-key). + +In the template, use the same names that you send in the template data: + +```liquid +Hi {{ variables.first_name }}, use this link to reset your password: {{ variables.reset_link }} +``` + +A send returns a message ID. It means that PostHog queued the email, not that the email was delivered. To see what happened to the email, [check its status](#check-an-emails-status), or open the template's **Sends** tab. + +Set up sending for your platform: + +- [Node.js](/docs/workflows/transactional-emails/node) +- [Python](/docs/workflows/transactional-emails/python) +- [Other languages, with the API](/docs/workflows/transactional-emails/api) + +## Retry safely with an idempotency key + +Without an `Idempotency-Key`, a retry after a timeout or a `503` can send the email twice. To prevent this, send a key that identifies this one email, such as the ID of the password reset request. A key is 1 to 255 visible ASCII characters, with no spaces. + +- **Same key, same request:** PostHog sends no new email. It returns `202` with the `message_id` of the first send and `"replayed": true`. PostHog remembers a key for 24 hours after it queues the email. +- **Same key, different request:** PostHog returns `422` with the code `idempotency_key_reused`. A request is the same when it has the same template, `to`, `distinct_id`, and `message_data`. +- **First request not finished:** PostHog returns `409` with the code `idempotency_key_in_flight`. Wait a few seconds, then retry with the same key. +- **Refused request:** When PostHog refuses a send, for example with `recipient_suppressed` or `quota_limited`, the key stays unused. You can send the same key again later. + +Keys are separate for each project. + +## Errors + +An error response has this shape: + +```json +{ + "type": "invalid_request", + "code": "recipient_suppressed", + "detail": "The recipient is on this project's suppression list after a hard bounce or a spam complaint, so the email can't be sent. Remove the address from the suppression list if it can receive email again.", + "attr": null +} +``` + +Use `code` in your error handling. The text in `detail` can change. + +| Status | `code` | What it means | What to do | +| --- | --- | --- | --- | +| `400` | Varies, with `"type": "validation_error"` | The body isn't valid. For example, `to` is missing or isn't an email address. `attr` names the field. | Fix the request. | +| `400` | `invalid_recipient` | The `to` address contains `{` or `}`. | Send a plain email address. | +| `400` | `invalid_idempotency_key` | The `Idempotency-Key` header isn't 1 to 255 visible ASCII characters. | Use a valid key. | +| `400` | `template_not_transactional` | The template isn't transactional. | [Make the template transactional](#create-a-transactional-email). | +| `400` | `template_not_sendable` | The template has no subject or no body. | Add them and publish the template. | +| `400` | `marketing_template` | The template is in a marketing category. | Move the template to a transactional category, or remove its category. | +| `400` | `transactional_email_paused` | The workflow that sends this template isn't active, or it was deleted. | Don't retry. A retry gets the same error. | +| `401` | `not_authenticated` or `authentication_failed` | The `Authorization` header is missing, or the key isn't valid. | Check that you send a valid project secret API key. | +| `403` | `permission_denied` | The key doesn't have the `hog_flow:write` scope, or the key belongs to a different project. | Use a key from this project with the correct scope. | +| `404` | `not_found` | The template doesn't exist in this project, or transactional emails aren't turned on for the project. | Check the template ID and the project. | +| `409` | `idempotency_key_in_flight` | A request with the same `Idempotency-Key` is still in progress. | Retry with the same key after a few seconds. | +| `422` | `idempotency_key_reused` | The `Idempotency-Key` was already used for a different request. | Use a new key for a new email. | +| `422` | `recipient_suppressed` | The address is on your project's [suppression list](/docs/workflows/opt-outs#the-suppression-list) after a hard bounce or a spam complaint. | Don't retry. Remove the address from the suppression list only if you know that it can receive email again. | +| `422` | `recipient_unreachable` | The address isn't valid, or its domain has no mail servers, so the email would bounce. | Check the address for a typo. Don't retry the same address. | +| `422` | `sending_paused` | Sending is paused for this transactional email because of its bounce or complaint rate. | Check the **Reputation** tab in Workflows. | +| `422` | `sending_suspended` | Email sending is suspended for your project. | Check the **Reputation** tab in Workflows. | +| `429` | `throttled` | You sent too many requests. | Wait for the number of seconds in the `Retry-After` header, then retry. | +| `429` | `quota_limited` | Your project is over its email quota. | Check your usage and [billing limits](/docs/billing/limits-alerts). A retry gets the same error until the project is under its quota. | +| `503` | `delivery_unavailable` | PostHog couldn't confirm that it queued the email. | Retry with backoff. Send the same `Idempotency-Key` so that the email doesn't go out twice. | + +If you make a template transactional or save it without a verified sender, PostHog returns `400` with the code `sender_unavailable`. + +### Rate limits + +Each project secret API key can send 600 requests per minute and 20,000 requests per hour. All the keys of one project share the same limits in total. A request over a limit gets a `429` with the code `throttled`. + +## Check an email's status + +Each send returns a message ID. Read the email's status with it, for 30 days after the send. Your platform's page shows how. + +| `status` | What it means | +| --- | --- | +| `queued` | The email waits to go out. | +| `sent` | PostHog handed the email to the email provider. | +| `failed` | The email wasn't sent. `reason` says why. | +| `delivered` | The recipient's mail server accepted the email. | +| `bounced` | The recipient's mail server refused the email. `reason` is `hard`, `soft`, or `unknown`. | +| `complained` | The recipient marked the email as spam. | + +A status only moves forward. For example, a `delivered` email never goes back to `sent`. New statuses can appear in the future, so handle a value you don't know. + +`reason` is `null` unless the email failed or bounced. A failed email has one of these reasons: + +- `provider_error` or `provider_rejected`: The email provider refused the email. +- `recipient_suppressed`, `recipient_unreachable`, or `missing_recipient`: PostHog skipped the email because of the recipient. +- `sending_paused` or `sending_suspended`: Sending is paused for the template or suspended for the project. Check the **Reputation** tab in Workflows. +- `canceled` or `workflow_unavailable`: The send stopped before the email went out. +- Another value, such as `timeout`: The send failed with this kind of error. New values can appear. + +A `failed` email can still change to `sent` if PostHog retries the send. + +### When a status isn't found + +The lookup returns `404` with the code `not_found` when: + +- The email was sent in the last few seconds. Its first status takes a moment to arrive, so retry after a short wait. +- The email is older than 30 days. PostHog keeps each status for 30 days after the send. +- No email of this project has the ID. + +## How transactional emails behave + +- **No opt-outs or frequency caps.** Transactional emails ignore marketing [opt-outs](/docs/workflows/opt-outs) and frequency caps. Use them only for emails that a person needs, never for marketing. +- **Suppression still applies.** PostHog doesn't send to an address on your suppression list. +- **Billing and quotas.** Transactional emails are billed and quota limited like other workflow emails. See [pricing](/pricing) and [billing limits](/docs/billing/limits-alerts). +- **Sending allowance.** Transactional emails count toward your project's [sending allowance](/docs/workflows/sending-reputation). When your project reaches its hourly or daily cap, emails wait in the queue until the allowance frees up. If a transactional email gets too many bounces or complaints, PostHog can pause its sending, and sends return `sending_paused`. +- **Engagement events.** If you [capture email engagement events](/docs/workflows/engagement-events), PostHog records them for transactional emails against the person whose distinct ID you send. +- **Where to see sends.** [Read the status](#check-an-emails-status) of an email with its `message_id`. The template's **Sends** tab lists each send with its log messages. The **Metrics** tab shows their metrics. Each send also appears on the recipient's person. diff --git a/contents/docs/workflows/transactional-emails/node.mdx b/contents/docs/workflows/transactional-emails/node.mdx new file mode 100644 index 000000000000..7c47deb2d655 --- /dev/null +++ b/contents/docs/workflows/transactional-emails/node.mdx @@ -0,0 +1,105 @@ +--- +title: Send transactional emails with Node.js +sidebar: Docs +showTitle: true +--- + +The [Node.js SDK](/docs/libraries/node) sends transactional emails from version 5.61.2. Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Install + +```bash +npm install posthog-node +``` + +## Set up the client + +Reuse the PostHog client your application already creates, and set `secretKey` to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. + +```js +import { PostHog } from "posthog-node" + +const posthog = new PostHog("", { + host: "https://us.i.posthog.com", + secretKey: process.env.POSTHOG_PROJECT_SECRET_API_KEY, + // This client only sends email, so it doesn't poll flag definitions with the secret key + enableLocalEvaluation: false, +}) +``` + +- **Local evaluation:** The client starts polling flag definitions with the secret key when it starts. A key with only the **Workflow** scope can't do that, so a client that only sends email needs `enableLocalEvaluation: false`. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too, and leave local evaluation on. +- **Host:** On PostHog Cloud, the client sends email to the app host of your region, even when `host` is an ingestion host such as `us.i.posthog.com`. For any other host, such as a reverse proxy or a self-hosted instance, set `appHost` to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. + +## Send an email + +```js +export async function sendPasswordReset({ userId, email, firstName, resetLink, resetRequestId }) { + const { data, error } = await posthog.emails.send({ + to: email, + templateId: "", + templateData: { first_name: firstName, reset_link: resetLink }, + distinctId: userId, + // Identifies this one email, so a retry never sends it twice + idempotencyKey: `password-reset-${resetRequestId}`, + }) + if (error) { + // An EmailError. Branch on error.code + throw error + } + return data.id +} +``` + +`send` returns `{ data, error }` and doesn't throw. `data.id` is the message ID. `error` is an `EmailError` with the `code` from the [errors table](/docs/workflows/transactional-emails#errors). After a `network_error` or a `503`, PostHog might have queued the email. Retry with the same idempotency key rather than sending the email through another provider, or the recipient can get it twice. + +### Timeouts and retries + +Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key. It waits about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. + +To change this, set `emailRequestTimeoutMs` and `emailRequestMaxRetries` on the client, or `maxRetries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotencyKey` that identifies the email. + +## Check an email's status + +```js +export async function emailBounced(messageId) { + const { data: status, error } = await posthog.emails.get(messageId) + if (error) { + if (error.code === "not_found") { + // The first status can take a few seconds to arrive. Try again later. + return null + } + throw error + } + return status.status === "bounced" +} +``` + +`get` returns `{ data, error }`. `data` has `id`, `status`, `createdAt`, `statusAt`, and `reason`. See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status). The client doesn't retry a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. + +## Test without sending email + +Pass your own `fetch` to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. + +```js +import assert from "node:assert/strict" +import { PostHog } from "posthog-node" + +const requests = [] +const posthog = new PostHog("phc_test", { + secretKey: "phs_test", + enableLocalEvaluation: false, + fetch: async (url, options) => { + requests.push({ url, options }) + return new Response(JSON.stringify({ message_id: "msg_1", replayed: false }), { status: 202 }) + }, +}) + +const { data } = await posthog.emails.send({ + to: "jane@example.com", + templateId: "", + idempotencyKey: "password-reset-1", +}) + +assert.equal(data.id, "msg_1") +assert.equal(requests[0].options.headers["Idempotency-Key"], "password-reset-1") +``` diff --git a/contents/docs/workflows/transactional-emails/python.mdx b/contents/docs/workflows/transactional-emails/python.mdx new file mode 100644 index 000000000000..e017761d6f33 --- /dev/null +++ b/contents/docs/workflows/transactional-emails/python.mdx @@ -0,0 +1,140 @@ +--- +title: Send transactional emails with Python +sidebar: Docs +showTitle: true +--- + +The [Python SDK](/docs/libraries/python) sends transactional emails from version 7.81.2. Before you start, [create a transactional email](/docs/workflows/transactional-emails#create-a-transactional-email) and [create a project secret API key](/docs/workflows/transactional-emails#create-an-api-key). + +## Install + +```bash +pip install posthog +``` + +For async code, install `posthog[async]` instead. + +## Set up the client + +Reuse the PostHog client your application already creates, and set `secret_key` to your project secret API key. The client still captures events with your project token. Shut the client down when your application exits, not after each send. + + + +```python file=Sync +import os + +from posthog import Posthog + +posthog = Posthog( + "", + host="https://us.i.posthog.com", + secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], +) +``` + +```python file=Async +import os + +from posthog import AsyncPosthog + +posthog = AsyncPosthog( + "", + host="https://us.i.posthog.com", + secret_key=os.environ["POSTHOG_PROJECT_SECRET_API_KEY"], +) +``` + + + +- **Host:** On PostHog Cloud, the client sends email to the app host of your region, even when `host` is an ingestion host such as `us.i.posthog.com`. For any other host, such as a reverse proxy or a self-hosted instance, set `app_host` to your PostHog app URL. The client refuses to send the secret key to a host that might be a proxy. +- **Local evaluation:** The client loads flag definitions only when it evaluates a flag. If your application [evaluates flags locally](/docs/feature-flags/local-evaluation) with the same client, give the key the **Feature flag** read scope too. +- **Email only:** In a process that only sends email, you can use `Emails` or `AsyncEmails` from `posthog.emails` instead of a full client. + +## Send an email + + + +```python file=Sync +def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: + # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. + result = posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + +```python file=Async +async def send_password_reset(user_id: str, email: str, first_name: str, reset_link: str, reset_request_id: str) -> str: + # Raises posthog.emails.EmailError when PostHog doesn't queue the email. Branch on error.code. + result = await posthog.emails.send( + to=email, + template_id="", + template_data={"first_name": first_name, "reset_link": reset_link}, + distinct_id=user_id, + # Identifies this one email, so a retry never sends it twice + idempotency_key=f"password-reset-{reset_request_id}", + ) + return result.id +``` + + + +`send` returns a `SendEmailResult`, and `result.id` is the message ID. A send that PostHog doesn't queue raises `EmailError`, with the `code` from the [errors table](/docs/workflows/transactional-emails#errors). A wrong argument raises `TypeError` or `ValueError` before any request. After a `network_error` or a `503`, PostHog might have queued the email. Retry with the same idempotency key rather than sending the email through another provider, or the recipient can get it twice. + +### Timeouts and retries + +Each request times out after 15 seconds. The client retries network errors, rate limits, an in-flight idempotency key, and a `503` up to two times, with the same idempotency key. It waits about 1 and then 2 seconds, or longer if the server's `Retry-After` asks for it. A `Retry-After` over 30 seconds returns the error at once. + +To change this, set `email_request_timeout_seconds` and `email_request_max_retries` on the client, or `max_retries` on one send. When a job queue already retries the send, set the retries to `0` and pass an `idempotency_key` that identifies the email. + +## Check an email's status + +```python +from posthog.emails import EmailError + +def email_bounced(message_id: str) -> bool | None: + try: + status = posthog.emails.get(message_id) + except EmailError as error: + if error.code == "not_found": + # The first status can take a few seconds to arrive. Try again later. + return None + raise + return status.status == "bounced" +``` + +`get` returns an `EmailStatus` with `id`, `status`, `created_at`, `status_at`, and `reason`. The two times are `datetime` objects. See [statuses and reasons](/docs/workflows/transactional-emails#check-an-emails-status). The client doesn't retry a `not_found`, so retry it yourself a few times, with a wait in between, before you treat the email as missing. A status that isn't there yet doesn't mean the email failed. + +## Test without sending email + +Pass your own HTTP transport to check what your code sends, without a network call. This checks your integration, not delivery. To check delivery, send a real email to your own address. + +```python +import httpx +from posthog.emails import AsyncEmails + +requests = [] + +def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(202, json={"message_id": "msg_1", "replayed": False}) + +async def test_send_password_reset(): + emails = AsyncEmails( + "phs_test", + host="https://us.posthog.com", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + result = await emails.send(to="jane@example.com", template_id="", idempotency_key="password-reset-1") + + assert result.id == "msg_1" + assert requests[0].headers["Idempotency-Key"] == "password-reset-1" +``` + +For synchronous code, pass a `requests.Session` with a test adapter to `Emails(session=...)`. Any library that mocks `requests` or `httpx` works too. diff --git a/src/navs/index.js b/src/navs/index.js index b459f2877c9b..b11db113c6e1 100644 --- a/src/navs/index.js +++ b/src/navs/index.js @@ -8502,6 +8502,33 @@ export const docsMenu = { icon: 'IconSend', color: 'orange', }, + { + name: 'Transactional emails', + }, + { + name: 'Send transactional emails', + url: '/docs/workflows/transactional-emails', + icon: 'IconLetter', + color: 'blue', + children: [ + { + name: 'Overview', + url: '/docs/workflows/transactional-emails', + }, + { + name: 'Node.js', + url: '/docs/workflows/transactional-emails/node', + }, + { + name: 'Python', + url: '/docs/workflows/transactional-emails/python', + }, + { + name: 'API', + url: '/docs/workflows/transactional-emails/api', + }, + ], + }, { name: 'Channels', },