PostHog.Emails (posthog v2.20.0)

Copy Markdown View Source

Send transactional email, such as a password reset or a receipt, and read its status.

An email goes out in one of two forms:

  • A template made in PostHog, sent by its ID with :template_id and :template_data.
  • Content your code rendered, named by :template_key, with :subject and :html, :text or both. To send a React email, render it to HTML in your app, for example with @react-email/render, and pass the result as :html. The SDK adds no rendering.

Setup

  1. In PostHog, open the email template under Workflows, make it transactional, and copy its ID. :template_id is that UUID. The code form needs no template.

  2. Create a project secret API key (phs_...) with the "Send transactional email" preset (transactional_email:write) in your project settings. Add transactional_email:read too if you read email statuses with a key that can't send. Keys made with hog_flow:write still work. The project API key (phc_...) captures events but cannot send email.

  3. Set it as :secret_key in your PostHog config:

    config :posthog,
      api_host: "https://eu.i.posthog.com",
      api_key: "phc_...",
      secret_key: "phs_..."

Examples

Send a template made in PostHog:

{:ok, %PostHog.Emails.SendResult{id: message_id}} =
  PostHog.Emails.send(
    to: "user@example.com",
    template_id: "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
    template_data: %{name: "Ada"},
    distinct_id: "user_123",
    idempotency_key: "welcome:user_123"
  )

Send content your code rendered:

PostHog.Emails.send(
  to: "user@example.com",
  template_key: "password-reset",
  subject: "Reset your password",
  html: rendered_html,
  distinct_id: "user_123",
  idempotency_key: "password-reset:#{reset.id}"
)

Read what happened to it:

{:ok, %PostHog.Emails.Status{status: status}} = PostHog.Emails.get(message_id)

Always pass :distinct_id. Without it, the email's events go to a person whose distinct ID is the email address, not to your user. The SDK never takes it from PostHog.set_context/2.

What a result means

{:ok, result} means PostHog queued the email. Delivery happens later: read it with get/2, or in the template's workflow in PostHog.

Where requests go

A client on PostHog Cloud sends to its region's app host, https://us.posthog.com or https://eu.posthog.com. For any other :api_host, such as a reverse proxy, set :app_host in the config, because email requests must not send the secret key to the proxy. Without it, a send returns the error missing_app_host.

:secret_key also turns on local feature flag evaluation, which fetches flag definitions from :api_host with that key. When :api_host is a proxy that must not see the key, set enable_local_evaluation: false as well.

Timeouts, retries and idempotency

Each request times out after :email_request_timeout_ms (15 seconds by default). A send retries network errors, rate limits, an idempotency key still in flight and an unavailable service :email_request_max_retries times (2 by default), waiting about 1 and then 2 seconds, or the server's Retry-After when it is longer. A Retry-After over 30 seconds fails at once with :retry_after_ms set. A monthly quota is never retried.

Every attempt sends the same idempotency key, so a retry never sends twice. Without :idempotency_key, the SDK makes a UUID for the call. When a job runner such as Oban already retries the send, pass max_retries: 0 and an :idempotency_key that names the email, such as "welcome:user_123". A generated key doesn't survive a job retry.

Sends and status reads block the calling process until they finish. The SDK keeps no email queue.

Errors

Every failure returns {:error, %PostHog.Emails.Error{}}. Branch on :code. The ones a setup usually hits:

  • missing_secret_key: the config has no :secret_key.
  • missing_app_host: the :api_host is not PostHog Cloud. Set :app_host.
  • authentication_failed (401) or permission_denied (403): the key is wrong, or it lacks transactional_email:write.
  • not_found (404): the template ID is wrong, or transactional email is not enabled for the project.
  • client_disabled: the PostHog instance is not running or has no :api_key, so it sends nothing.
  • client_closed: the PostHog instance stopped while a call waited to retry, so the retry was not sent.

An option the SDK refuses returns an error with an invalid_* code, such as invalid_template_id, and the name :validation_error, before any request.

Testing

Requests go through the configured :api_client_module, so a Mox mock of PostHog.API.Client sees every email request without network calls.

Summary

Types

Options for send/2. Pass :template_id or :template_key, never both.

Functions

Reads the status of an email that send/2 queued, for 30 days after the send.

Queues one email, from a template made in PostHog or from content your code rendered.

Types

send_option()

@type send_option() ::
  {:to, String.t()}
  | {:distinct_id, String.t() | nil}
  | {:idempotency_key, String.t() | nil}
  | {:max_retries, non_neg_integer() | nil}
  | {:template_id, String.t()}
  | {:template_data, map() | nil}
  | {:template_key, String.t()}
  | {:subject, String.t()}
  | {:html, String.t() | nil}
  | {:text, String.t() | nil}
  | {:from, String.t() | nil}
  | {:reply_to, String.t() | [String.t()] | nil}
  | {:capture_engagement, boolean() | nil}

Options for send/2. Pass :template_id or :template_key, never both.

Every send takes:

  • :to - one recipient email address. Required.
  • :distinct_id - the recipient's distinct ID in your product. The email's events go to this person.
  • :idempotency_key - 1 to 255 visible ASCII characters. A send with a key PostHog already accepted queues nothing and returns the first send's result. Template sends and code sends share one set of keys: a key already used for another email returns idempotency_key_reused.
  • :max_retries - overrides the config's :email_request_max_retries for this send. 0 turns retries off.

A template made in PostHog takes:

  • :template_id - the UUID of a template made transactional in PostHog.
  • :template_data - a map of values for the template's {{ variables.* }} placeholders.

Content your code rendered takes:

  • :template_key - names the email, such as "password-reset", so its metrics, logs and status group under it in PostHog. Letters, digits, - and _, at most 400.
  • :subject - the subject line. Required.
  • :html and :text - the bodies. Give one or both. Without :text, PostHog derives it from :html.
  • :from - a verified sender, such as "Acme <security@example.com>". Without it, the project's default sender.
  • :reply_to - one address or a list of addresses.
  • :capture_engagement - whether PostHog captures the email's opens and clicks as events of the recipient. Defaults to true. A project whose admin turned engagement capture off captures nothing, whatever the send asks.

An option set to nil counts as left out.

Functions

get(name \\ PostHog, message_id)

Reads the status of an email that send/2 queued, for 30 days after the send.

Returns {:ok, %PostHog.Emails.Status{}} or {:error, %PostHog.Emails.Error{}}. The error code is not_found when no email of this project has the ID, when the email is older than 30 days, or for a few seconds after the send. A key with only transactional_email:read can read statuses but not send. Network errors, rate limits and an unavailable service are retried.

Examples

{:ok, %PostHog.Emails.Status{status: "bounced", reason: reason}} =
  PostHog.Emails.get(message_id)

send(name \\ PostHog, options)

Queues one email, from a template made in PostHog or from content your code rendered.

Returns {:ok, %PostHog.Emails.SendResult{}} once PostHog queued the email, or {:error, %PostHog.Emails.Error{}}. See send_option/0 for the options. name is the PostHog instance to send through, PostHog by default.

Examples

PostHog.Emails.send(
  to: "user@example.com",
  template_id: "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b",
  distinct_id: "user_123"
)

PostHog.Emails.send(MyPostHog,
  to: "user@example.com",
  template_key: "receipt",
  subject: "Your receipt",
  html: html,
  distinct_id: "user_123"
)