From c5c493396f23d1d762e127bd19b1ad0b62293571 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:48:11 +0000 Subject: [PATCH 01/13] feat(emails): send transactional email and read its status Add PostHog.Emails.send/2 for a template made in PostHog (template_id, template_data) or content the app rendered (template_key, subject, html and/or text), and PostHog.Emails.get/2 for an email's status. Both use the project secret key against the app host, with one idempotency key across retries. New config options: app_host, email_request_timeout_ms, email_request_max_retries and migrated_from. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-send.md | 5 + README.md | 1 + lib/posthog/config.ex | 18 +- lib/posthog/config_schema.ex | 49 +- lib/posthog/emails.ex | 821 +++++++++++++++++++++++++++++++ lib/posthog/emails/error.ex | 85 ++++ lib/posthog/emails/results.ex | 68 +++ lib/posthog/registry.ex | 10 + lib/posthog/supervisor.ex | 2 +- mix.exs | 7 + public_api.snapshot | 38 ++ test/posthog/emails_test.exs | 746 ++++++++++++++++++++++++++++ 12 files changed, 1842 insertions(+), 8 deletions(-) create mode 100644 .sampo/changesets/emails-send.md create mode 100644 lib/posthog/emails.ex create mode 100644 lib/posthog/emails/error.ex create mode 100644 lib/posthog/emails/results.ex create mode 100644 test/posthog/emails_test.exs diff --git a/.sampo/changesets/emails-send.md b/.sampo/changesets/emails-send.md new file mode 100644 index 0000000..176c978 --- /dev/null +++ b/.sampo/changesets/emails-send.md @@ -0,0 +1,5 @@ +--- +hex/posthog: minor +--- + +Add `PostHog.Emails` to send transactional email and read its status. `PostHog.Emails.send/2` queues one email from a template made in PostHog (`template_id` and `template_data`) or from content your code rendered (`template_key`, `subject`, and `html` and/or `text`). `PostHog.Emails.get/2` reads what happened to it. Both need a project secret API key in `secret_key`. New config options: `app_host`, `email_request_timeout_ms`, `email_request_max_retries` and `migrated_from`. diff --git a/README.md b/README.md index 1f0df97..e2c1eb8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ SDK usage examples and code snippets live in the official documentation so they - [Elixir library docs](https://posthog.com/docs/libraries/elixir) - [Phoenix framework docs](https://posthog.com/docs/libraries/phoenix) +- [Transactional email (HexDocs)](https://hexdocs.pm/posthog/PostHog.Emails.html) - [OpenTelemetry span context (HexDocs)](https://hexdocs.pm/posthog/PostHog.OpenTelemetry.ContextSpanProcessor.html) - [API reference (HexDocs)](https://hexdocs.pm/posthog) diff --git a/lib/posthog/config.ex b/lib/posthog/config.ex index f82062d..22f9971 100644 --- a/lib/posthog/config.ex +++ b/lib/posthog/config.ex @@ -118,8 +118,9 @@ defmodule PostHog.Config do ## Remarks - String `:api_key`, `:secret_key`, and `:api_host` values are trimmed before validation. A blank - `:api_host` falls back to the default PostHog US ingestion host. + String `:api_key`, `:secret_key`, `:app_host`, and `:api_host` values are trimmed before + validation. A blank `:api_host` falls back to the default PostHog US ingestion host, and a blank + `:app_host` counts as unset. """ @spec validate(options()) :: {:ok, config()} | {:error, NimbleOptions.ValidationError.t()} @@ -179,7 +180,14 @@ defmodule PostHog.Config do end) |> then(fn normalized_options -> if Keyword.has_key?(normalized_options, :secret_key) do - Keyword.update!(normalized_options, :secret_key, &normalize_secret_key/1) + Keyword.update!(normalized_options, :secret_key, &normalize_optional_string/1) + else + normalized_options + end + end) + |> then(fn normalized_options -> + if Keyword.has_key?(normalized_options, :app_host) do + Keyword.update!(normalized_options, :app_host, &normalize_optional_string/1) else normalized_options end @@ -197,14 +205,14 @@ defmodule PostHog.Config do defp normalize_api_key(nil), do: "" defp normalize_api_key(api_key), do: api_key - defp normalize_secret_key(secret_key) when is_binary(secret_key) do + defp normalize_optional_string(secret_key) when is_binary(secret_key) do case String.trim(secret_key) do "" -> nil value -> value end end - defp normalize_secret_key(secret_key), do: secret_key + defp normalize_optional_string(secret_key), do: secret_key defp normalize_api_host(api_host) when is_binary(api_host) do api_host diff --git a/lib/posthog/config_schema.ex b/lib/posthog/config_schema.ex index a1c742b..0889281 100644 --- a/lib/posthog/config_schema.ex +++ b/lib/posthog/config_schema.ex @@ -43,9 +43,43 @@ defmodule PostHog.Config.Schema do type: {:or, [:string, nil]}, default: nil, doc: """ - A privileged project secret (`phs_`) or appropriately scoped personal API key (`phx_`) - required only to fetch local feature flag definitions directly from PostHog. A configured + A privileged project secret (`phs_`) or appropriately scoped personal API key (`phx_`). + Fetches local feature flag definitions directly from PostHog. A configured `flag_definition_cache_provider` can supply definitions without this key. + `PostHog.Emails` needs a project secret API key with the `hog_flow:write` scope to send + email, or `hog_flow:read` to read an email's status. + """ + ], + app_host: [ + type: {:or, [:string, nil]}, + default: nil, + doc: """ + The PostHog app host that `PostHog.Emails` sends to, such as `https://posthog.example.com` + for a self-hosted instance. Without it, a PostHog Cloud `api_host` maps to its region's app + host, and any other `api_host` makes email calls fail with `missing_app_host`, because a + reverse proxy for ingestion must not receive the secret key. + """ + ], + email_request_timeout_ms: [ + type: :pos_integer, + default: 15_000, + doc: "Maximum time in milliseconds to wait for each `PostHog.Emails` request." + ], + email_request_max_retries: [ + type: :non_neg_integer, + default: 2, + doc: """ + Number of retries for a `PostHog.Emails` request after a network error, a rate limit, an + idempotency key still in flight, or an unavailable service. Set to 0 when a job runner + already retries the send. + """ + ], + migrated_from: [ + type: {:or, [{:custom, __MODULE__, :header_token, []}, nil]}, + default: nil, + doc: """ + The email provider you moved from, such as `"resend"`. Sent with every `PostHog.Emails` + request as `X-PostHog-Migrated-From`. """ ], enable_local_evaluation: [ @@ -151,6 +185,17 @@ defmodule PostHog.Config.Schema do ] ++ shared() end + @doc false + def header_token(value) when is_binary(value) do + if value =~ ~r/\A[\x21-\x7e]+\z/ do + {:ok, value} + else + {:error, "expected visible ASCII characters without spaces, got: #{inspect(value)}"} + end + end + + def header_token(value), do: {:error, "expected a string, got: #{inspect(value)}"} + def convenience do [ enable: [ diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex new file mode 100644 index 0000000..f0b6d3b --- /dev/null +++ b/lib/posthog/emails.ex @@ -0,0 +1,821 @@ +defmodule PostHog.Emails do + @moduledoc """ + 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 Workflow write access (`hog_flow:write`) + in your project settings. 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 the proxy must not receive the secret key. Without it, a send returns + the error `missing_app_host`. + + ## 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 run in the calling process. 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 + `hog_flow: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. + + 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. + """ + + import Kernel, except: [send: 2] + + require Logger + + alias PostHog.Emails.Error + alias PostHog.Emails.SendResult + alias PostHog.Emails.Status + alias PostHog.Emails.Warning + + @send_path "/api/projects/@current/messaging/send/email/" + @status_path "/api/projects/@current/messaging/emails/" + @us_app_host "https://us.posthog.com" + @eu_app_host "https://eu.posthog.com" + @cloud_app_hosts %{ + "https://us.i.posthog.com" => @us_app_host, + "https://us.posthog.com" => @us_app_host, + "https://app.posthog.com" => @us_app_host, + "https://eu.i.posthog.com" => @eu_app_host, + "https://eu.posthog.com" => @eu_app_host + } + @max_retry_delay_ms 30_000 + @default_timeout_ms 15_000 + @default_max_retries 2 + @log_metadata [posthog_skip_capture: true] + + @shared_fields [:to, :distinct_id, :idempotency_key, :max_retries] + @template_fields @shared_fields ++ [:template_id, :template_data] + @code_fields @shared_fields ++ + [:template_key, :subject, :html, :text, :from, :reply_to, :capture_engagement] + + @typedoc """ + 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 "`. 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. + """ + @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} + + @doc """ + 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 `t: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" + ) + """ + @spec send(PostHog.supervisor_name(), [send_option()] | map()) :: + {:ok, SendResult.t()} | {:error, Error.t()} + def send(name \\ PostHog, options) do + with {:ok, prepared} <- prepare_send(options), + {:ok, connection} <- connect(name, :send), + {:ok, result} <- deliver(connection, prepared) do + log_warnings(result) + {:ok, result} + end + rescue + exception -> {:error, unexpected(exception, "Sending the email failed unexpectedly.")} + end + + @doc """ + 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 `hog_flow: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) + """ + @spec get(PostHog.supervisor_name(), String.t()) :: {:ok, Status.t()} | {:error, Error.t()} + def get(name \\ PostHog, message_id) do + with {:ok, message_id} <- check_message_id(message_id), + {:ok, connection} <- connect(name, :get) do + path = @status_path <> message_id <> "/" + + with_retries(connection, connection.max_retries, fn -> + connection + |> request(:get, path, headers(connection), nil) + |> parse_status_reply() + end) + end + rescue + exception -> {:error, unexpected(exception, "Reading the email status failed unexpectedly.")} + end + + defp deliver(connection, prepared) do + headers = [ + {"content-type", "application/json"}, + {"idempotency-key", prepared.idempotency_key} + | headers(connection) + ] + + with_retries(connection, prepared.max_retries || connection.max_retries, fn -> + connection + |> request(:post, @send_path, headers, prepared.body) + |> parse_send_reply() + end) + end + + # Options + + defp prepare_send(options) when is_list(options) or is_map(options) do + if is_map(options) or Keyword.keyword?(options) do + options |> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Map.new() |> prepare() + else + invalid_options() + end + end + + defp prepare_send(_options), do: invalid_options() + + defp invalid_options, + do: {:error, Error.new("invalid_form", "send takes a keyword list or a map of options.")} + + defp prepare(options) do + with :ok <- check_recipient(options), + {:ok, content} <- content(options), + {:ok, idempotency_key} <- idempotency_key(options), + {:ok, max_retries} <- max_retries(options), + {:ok, body} <- encode(content, options) do + {:ok, %{body: body, idempotency_key: idempotency_key, max_retries: max_retries}} + end + end + + defp check_recipient(options) do + cond do + not nonblank?(options[:to]) -> + invalid("invalid_recipient", "to must be one email address as a nonempty string.", "to") + + Map.has_key?(options, :distinct_id) and not nonempty?(options.distinct_id) -> + invalid("invalid_distinct_id", "distinct_id must be a nonempty string.", "distinct_id") + + true -> + :ok + end + end + + defp content(%{template_id: _, template_key: _}), do: invalid_form() + defp content(%{template_id: _} = options), do: template_content(options) + defp content(%{template_key: _} = options), do: code_content(options) + defp content(_options), do: invalid_form() + + defp invalid_form, + do: + {:error, + Error.new( + "invalid_form", + "Send either template_id, or template_key with subject and html or text." + )} + + defp template_content(%{template_id: template_id} = options) do + template_data = Map.get(options, :template_data, %{}) + + cond do + not uuid?(template_id) -> + invalid("invalid_template_id", "template_id must be the template's UUID.", "template_id") + + not is_map(template_data) -> + invalid("invalid_template_data", "template_data must be a map.", "template_data") + + true -> + with :ok <- refuse_fields_outside(options, :template) do + {:ok, + options + |> Map.take([:to, :distinct_id]) + |> Map.merge(%{template_id: String.downcase(template_id), message_data: template_data})} + end + end + end + + defp code_content(%{template_key: template_key} = options) do + with :ok <- check_template_key(template_key), + :ok <- refuse_fields_outside(options, :code), + :ok <- check_code_fields(options) do + {:ok, + options + |> Map.take([:to, :distinct_id, :template_key, :subject, :html, :text, :from]) + |> Map.put(:capture_engagement, Map.get(options, :capture_engagement, true)) + |> put_reply_to(options)} + end + end + + defp check_template_key(template_key) do + if is_binary(template_key) and template_key =~ ~r/\A[a-zA-Z0-9_-]{1,400}\z/ do + :ok + else + invalid( + "invalid_template_key", + "template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.", + "template_key" + ) + end + end + + defp check_code_fields(options) do + with :ok <- check_content(options) do + check_sender(options) + end + end + + defp check_content(options) do + cond do + not is_binary(options[:subject]) -> + invalid("invalid_form", "A send with template_key needs a subject.", "subject") + + field = Enum.find([:html, :text], ¬_string?(options, &1)) -> + invalid("invalid_form", "#{field} must be a string.", to_string(field)) + + not Map.has_key?(options, :html) and not Map.has_key?(options, :text) -> + invalid("invalid_form", "A send with template_key needs html, text or both.", "html") + + true -> + :ok + end + end + + defp check_sender(options) do + cond do + not_string?(options, :from) -> + invalid("invalid_form", "from must be a string.", "from") + + Map.has_key?(options, :reply_to) and not addresses?(options.reply_to) -> + invalid( + "invalid_form", + "reply_to must be one address or a list of addresses.", + "reply_to" + ) + + not is_boolean(Map.get(options, :capture_engagement, true)) -> + invalid("invalid_form", "capture_engagement must be true or false.", "capture_engagement") + + true -> + :ok + end + end + + defp not_string?(options, field), + do: Map.has_key?(options, field) and not is_binary(Map.fetch!(options, field)) + + defp put_reply_to(content, %{reply_to: reply_to}) when is_binary(reply_to), + do: Map.put(content, :reply_to, [reply_to]) + + defp put_reply_to(content, %{reply_to: reply_to}), do: Map.put(content, :reply_to, reply_to) + defp put_reply_to(content, _options), do: content + + defp addresses?(address) when is_binary(address), do: nonblank?(address) + defp addresses?(addresses) when is_list(addresses), do: Enum.all?(addresses, &nonblank?/1) + defp addresses?(_addresses), do: false + + defp refuse_fields_outside(options, form) do + {fields, other_fields} = + case form do + :template -> {@template_fields, @code_fields} + :code -> {@code_fields, @template_fields} + end + + case Enum.find(Map.keys(options), &(&1 not in fields)) do + nil -> + :ok + + field -> + {:error, field_outside_error(form, field, field in other_fields)} + end + end + + defp field_outside_error(form, field, other_form_takes_it?) do + form_field = if form == :template, do: "template_id", else: "template_key" + message = "A send with #{form_field} doesn't take #{field}, so no email was sent." + + if other_form_takes_it? do + {why, how} = + case form do + :template -> + {"A template made in PostHog sets its own sender, subject and content, and follows the project's engagement setting.", + "send content from your code with template_key"} + + :code -> + {"A send with template_key takes its content from your code.", + "send a template made in PostHog with template_id"} + end + + Error.new("invalid_form", message, + field: to_string(field), + why: why, + fix: "Remove #{field}, or #{how}." + ) + else + Error.new("invalid_form", message, + field: to_string(field), + fix: "Remove #{field} and send again." + ) + end + end + + defp idempotency_key(%{idempotency_key: key}) do + if is_binary(key) and key =~ ~r/\A[\x21-\x7e]{1,255}\z/ do + {:ok, key} + else + invalid( + "invalid_idempotency_key", + "idempotency_key must be 1 to 255 visible ASCII characters without spaces.", + "idempotency_key" + ) + end + end + + defp idempotency_key(_options), do: {:ok, uuid4()} + + defp max_retries(%{max_retries: retries}) when is_integer(retries) and retries >= 0, + do: {:ok, retries} + + defp max_retries(%{max_retries: _retries}), + do: + invalid("invalid_max_retries", "max_retries must be a nonnegative integer.", "max_retries") + + defp max_retries(_options), do: {:ok, nil} + + defp encode(content, options) do + case Jason.encode(content) do + {:ok, body} -> + {:ok, body} + + {:error, _reason} when is_map_key(options, :template_id) -> + invalid("invalid_template_data", "template_data must encode as JSON.", "template_data") + + {:error, _reason} -> + {:error, Error.new("invalid_form", "Every string option must be valid UTF-8.")} + end + end + + defp check_message_id(message_id) do + if uuid?(message_id) do + {:ok, String.downcase(message_id)} + else + invalid( + "invalid_message_id", + "message_id must be the message ID that send/2 returned.", + "message_id" + ) + end + end + + defp uuid?(value), + do: + is_binary(value) and + value =~ ~r/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + + defp nonempty?(value), do: is_binary(value) and value != "" + defp nonblank?(value), do: is_binary(value) and String.trim(value) != "" + + defp invalid(code, message, field), do: {:error, Error.new(code, message, field: field)} + + defp uuid4 do + <> = :crypto.strong_rand_bytes(16) + + <> + |> Base.encode16(case: :lower) + |> then(fn <> -> + Enum.join([p1, p2, p3, p4, p5], "-") + end) + end + + # Connection + + defp connect(name, operation) do + nothing_done = + if operation == :send, do: "no email was sent", else: "no email status was read" + + config = PostHog.Registry.config(name) + + with :ok <- check_enabled(config, name, nothing_done), + {:ok, app_host} <- app_host(config), + {:ok, secret_key} <- secret_key(name, operation) do + {:ok, + %{ + api_client: config.api_client, + app_host: app_host, + secret_key: secret_key, + migrated_from: Map.get(config, :migrated_from), + timeout_ms: Map.get(config, :email_request_timeout_ms, @default_timeout_ms), + max_retries: Map.get(config, :email_request_max_retries, @default_max_retries), + sleep: Map.get(config, :email_sleep, &Process.sleep/1) + }} + end + end + + defp check_enabled(%{enabled: true, api_client: %{}}, _name, _nothing_done), do: :ok + + defp check_enabled(_config, name, nothing_done) do + {:error, + Error.new( + "client_disabled", + "The PostHog instance #{inspect(name)} is not running or has no api_key, so #{nothing_done}." + )} + end + + # A custom host is often an ingestion proxy, which must not receive the secret key. + defp app_host(config) do + case Map.get(config, :app_host) do + app_host when is_binary(app_host) -> + app_host = String.trim_trailing(app_host, "/") + {:ok, Map.get(@cloud_app_hosts, app_host, app_host)} + + nil -> + case Map.fetch(@cloud_app_hosts, String.trim_trailing(config.api_host, "/")) do + {:ok, app_host} -> + {:ok, app_host} + + :error -> + {:error, + Error.new( + "missing_app_host", + "The config's api_host is not PostHog Cloud, so the SDK can't tell where to send email. Set app_host to your PostHog app host." + )} + end + end + end + + defp secret_key(name, operation) do + case PostHog.Registry.secret_key(name) do + %PostHog.Config.Secret{} = secret -> + {:ok, PostHog.Config.Secret.reveal(secret)} + + nil -> + message = + if operation == :send, + do: + "Sending email needs secret_key, a project secret API key with the hog_flow:write scope.", + else: + "Reading an email status needs secret_key, a project secret API key with the hog_flow:read or hog_flow:write scope." + + {:error, Error.new("missing_secret_key", message)} + end + end + + defp headers(connection) do + headers = [ + {"authorization", "Bearer #{connection.secret_key}"}, + {"accept", "application/json"}, + {"user-agent", PostHog.API.Client.user_agent()} + ] + + case connection.migrated_from do + nil -> headers + migrated_from -> [{"x-posthog-migrated-from", migrated_from} | headers] + end + end + + # Requests + + defp request(connection, method, path, headers, body) do + options = + [ + headers: headers, + retry: false, + redirect: false, + compress_body: false, + receive_timeout: connection.timeout_ms + ] ++ if(body, do: [body: body], else: []) + + %{module: module, client: client} = connection.api_client + + case module.request(client, method, connection.app_host <> path, options) do + {:ok, %{status: status} = response} when is_integer(status) -> + {:ok, status, fields(Map.get(response, :body)), + retry_after_ms(Map.get(response, :headers))} + + {:error, _reason} -> + {:error, Error.new("network_error", "The request to PostHog failed.")} + end + end + + defp fields(body) when is_map(body), do: body + + defp fields(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{} = fields} -> fields + _other -> %{} + end + end + + defp fields(_body), do: %{} + + defp with_retries(connection, max_retries, attempt_once, attempt \\ 0) do + case attempt_once.() do + {:ok, result} -> + {:ok, result} + + {:error, %Error{} = error} -> + case retry_delay_ms(error, attempt, max_retries) do + nil -> + {:error, error} + + delay_ms -> + connection.sleep.(delay_ms) + with_retries(connection, max_retries, attempt_once, attempt + 1) + end + end + end + + # A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. + defp retryable?(%Error{status_code: nil, code: code}), do: code == "network_error" + defp retryable?(%Error{status_code: 409, code: code}), do: code == "idempotency_key_in_flight" + # Waiting doesn't fix a monthly quota. + defp retryable?(%Error{status_code: 429, code: code}), do: code != "quota_limited" + defp retryable?(%Error{status_code: status}), do: status == 503 + + defp retry_delay_ms(error, attempt, max_retries) do + cond do + attempt >= max_retries or not retryable?(error) -> + nil + + # Retrying before a longer Retry-After would break the server's rate limit. + (error.retry_after_ms || 0) > @max_retry_delay_ms -> + nil + + true -> + backoff = + min(@max_retry_delay_ms, round(1000 * 2 ** attempt * (0.8 + :rand.uniform() * 0.4))) + + max(backoff, error.retry_after_ms || 0) + end + end + + # Replies + + defp parse_send_reply({:ok, status, fields, _retry_after}) when status in 200..299 do + case text(fields["message_id"]) do + nil -> + {:error, + Error.new("api_error", "PostHog answered without a message ID.", status_code: status)} + + id -> + {:ok, + %SendResult{ + id: id, + replayed: fields["replayed"] == true, + warnings: warnings(fields["warnings"]) + }} + end + end + + defp parse_send_reply(reply), do: reply_error(reply) + + defp warnings(warnings) when is_list(warnings) do + for %{} = warning <- warnings do + %Warning{ + code: text(warning["code"]) || "warning", + message: text(warning["detail"]) || text(warning["message"]) || "", + fix: text(warning["fix"]) + } + end + end + + defp warnings(_warnings), do: [] + + defp log_warnings(%SendResult{warnings: warnings}) do + for warning <- warnings do + Logger.warning("PostHog email warning #{warning.code}: #{warning.message}", @log_metadata) + end + end + + defp parse_status_reply({:ok, status, fields, _retry_after}) when status in 200..299 do + with id when is_binary(id) <- text(fields["id"]), + delivery_status when is_binary(delivery_status) <- text(fields["status"]), + {:ok, created_at, _offset} <- DateTime.from_iso8601(text(fields["created_at"]) || ""), + {:ok, status_at, _offset} <- DateTime.from_iso8601(text(fields["status_at"]) || "") do + {:ok, + %Status{ + id: id, + status: delivery_status, + created_at: created_at, + status_at: status_at, + reason: text(fields["reason"]) + }} + else + _missing -> + {:error, + Error.new("api_error", "PostHog answered without an email status.", status_code: status)} + end + end + + defp parse_status_reply(reply), do: reply_error(reply) + + defp reply_error({:error, error}), do: {:error, error} + + defp reply_error({:ok, status, fields, retry_after_ms}) do + {:error, + Error.new( + text(fields["code"]) || "api_error", + text(fields["detail"]) || "PostHog answered HTTP #{status}.", + status_code: status, + why: text(fields["why"]), + fix: text(fields["fix"]), + docs_url: text(fields["docs_url"]), + field: text(fields["attr"]), + retry_after_ms: retry_after_ms + )} + end + + defp text(value) when is_binary(value) and value != "", do: value + defp text(_value), do: nil + + defp unexpected(exception, message) do + Logger.error("#{message} #{inspect(exception.__struct__)}", @log_metadata) + Error.new("unexpected_error", message) + end + + # Retry-After: delay-seconds or an HTTP date. + + defp retry_after_ms(headers) do + case header_value(headers, "retry-after") do + value when is_binary(value) -> parse_retry_after(String.trim(value)) + _missing -> nil + end + end + + defp header_value(headers, name) when is_map(headers) do + Enum.find_value(headers, fn {key, value} -> + if String.downcase(to_string(key)) == name, do: List.wrap(value) |> List.first() + end) + end + + defp header_value(headers, name) when is_list(headers) do + Enum.find_value(headers, fn + {key, value} -> if String.downcase(to_string(key)) == name, do: value + _other -> nil + end) + end + + defp header_value(_headers, _name), do: nil + + defp parse_retry_after(value) do + case Integer.parse(value) do + {seconds, ""} when seconds >= 0 -> seconds * 1000 + _other -> parse_http_date(value) + end + end + + @months ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) + + defp parse_http_date(value) do + with [_, day, month, year, time] <- + Regex.run(~r/\A\w{3}, (\d{2}) (\w{3}) (\d{4}) (\d{2}:\d{2}:\d{2}) GMT\z/, value), + month when is_integer(month) <- Enum.find_index(@months, &(&1 == month)), + {:ok, date} <- Date.new(String.to_integer(year), month + 1, String.to_integer(day)), + {:ok, time} <- Time.from_iso8601(time), + {:ok, at} <- DateTime.new(date, time) do + max(0, DateTime.diff(at, DateTime.utc_now(), :millisecond)) + else + _invalid -> nil + end + end +end diff --git a/lib/posthog/emails/error.ex b/lib/posthog/emails/error.ex new file mode 100644 index 0000000..7ecc600 --- /dev/null +++ b/lib/posthog/emails/error.ex @@ -0,0 +1,85 @@ +defmodule PostHog.Emails.Error do + @moduledoc """ + PostHog did not queue the email or return its status, or the SDK could not tell whether it did. + + Branch on `:code`, PostHog's stable error code, or on `:name`, a coarse category of the + failure. The message never contains the secret key or the request body. + + ## Fields + + - `:name` - the category, one of `t:name/0`. + - `:code` - PostHog's error code, such as `"quota_limited"` or `"recipient_suppressed"`, or an + SDK code: `"missing_secret_key"`, `"missing_app_host"`, `"client_disabled"`, + `"network_error"`, `"api_error"` (an answer without a code), `"unexpected_error"`, or an + `"invalid_*"` code for an option the SDK refused before any request. + - `:message` - human-readable message. + - `:status_code` - the HTTP status, when PostHog answered. + - `:why` and `:fix` - why PostHog refused the email and how to fix it, when it said. + - `:docs_url` - a link to the docs for the error, when PostHog sent one. + - `:field` - the option the error is about, such as `"to"`. + - `:retry_after_ms` - how long PostHog asked the client to wait before it sends again. + """ + + @typedoc "A coarse category of the failure." + @type name() :: + :validation_error + | :missing_api_key + | :invalid_permission + | :not_found + | :concurrent_idempotent_requests + | :invalid_idempotent_request + | :rate_limit_exceeded + | :monthly_quota_exceeded + | :service_unavailable + | :application_error + + @typedoc "An email request that failed." + @type t() :: %__MODULE__{ + name: name(), + code: String.t(), + message: String.t(), + status_code: pos_integer() | nil, + why: String.t() | nil, + fix: String.t() | nil, + docs_url: String.t() | nil, + field: String.t() | nil, + retry_after_ms: non_neg_integer() | nil + } + + defexception [ + :name, + :code, + :message, + :status_code, + :why, + :fix, + :docs_url, + :field, + :retry_after_ms + ] + + @doc false + @spec new(String.t(), String.t(), keyword()) :: t() + def new(code, message, fields \\ []) do + status_code = fields[:status_code] + struct!(%__MODULE__{name: name(status_code, code), code: code, message: message}, fields) + end + + defp name(_status, "missing_secret_key"), do: :missing_api_key + defp name(401, _code), do: :missing_api_key + defp name(nil, "network_error"), do: :service_unavailable + defp name(nil, "invalid_" <> _), do: :validation_error + defp name(nil, _code), do: :application_error + defp name(400, _code), do: :validation_error + defp name(403, _code), do: :invalid_permission + defp name(404, _code), do: :not_found + defp name(409, "template_outdated"), do: :validation_error + defp name(409, _code), do: :concurrent_idempotent_requests + # The template send route also answers 422 for send checks such as recipient_suppressed. + defp name(422, "idempotency_key_reused"), do: :invalid_idempotent_request + defp name(422, _code), do: :validation_error + defp name(429, "quota_limited"), do: :monthly_quota_exceeded + defp name(429, _code), do: :rate_limit_exceeded + defp name(503, _code), do: :service_unavailable + defp name(_status, _code), do: :application_error +end diff --git a/lib/posthog/emails/results.ex b/lib/posthog/emails/results.ex new file mode 100644 index 0000000..948f670 --- /dev/null +++ b/lib/posthog/emails/results.ex @@ -0,0 +1,68 @@ +defmodule PostHog.Emails.Warning do + @moduledoc """ + A warning PostHog returned with a queued email. `PostHog.Emails.send/2` also logs it. + """ + + @typedoc "A warning about a queued email." + @type t() :: %__MODULE__{code: String.t(), message: String.t(), fix: String.t() | nil} + + @enforce_keys [:code, :message] + defstruct [:code, :message, :fix] +end + +defmodule PostHog.Emails.SendResult do + @moduledoc """ + The queued email. A result means PostHog queued the email, not that it was delivered. + + ## Fields + + - `:id` - the queued email's message ID. Read its status with `PostHog.Emails.get/2`. + - `:replayed` - `true` when PostHog had already accepted this idempotency key and returned the + first send's result. + - `:warnings` - the warnings PostHog returned with the email. + """ + + @typedoc "A queued email." + @type t() :: %__MODULE__{ + id: String.t(), + replayed: boolean(), + warnings: [PostHog.Emails.Warning.t()] + } + + @enforce_keys [:id] + defstruct [:id, replayed: false, warnings: []] +end + +defmodule PostHog.Emails.Status do + @moduledoc """ + What happened to an email that `PostHog.Emails.send/2` queued. + + ## Fields + + - `:id` - the message ID that `send/2` returned. + - `:status` - one of `"queued"`, `"sent"`, `"failed"`, `"delivered"`, `"bounced"` or + `"complained"`. Later versions of PostHog can return values this list does not have yet. + - `"queued"`: the email waits to go out. + - `"sent"`: PostHog handed the email to the email provider. + - `"failed"`: the email was not sent, and `:reason` says why. + - `"delivered"`: the recipient's mail server accepted the email. + - `"bounced"` and `"complained"`: the recipient's mail provider reported them after delivery. + - `:created_at` - when PostHog queued the email. + - `:status_at` - when the email reached its current status. + - `:reason` - why a failed email was not sent, such as `"provider_error"` or + `"recipient_suppressed"`, or the bounce type of a bounced email: `"hard"`, `"soft"` or + `"unknown"`. `nil` otherwise. + """ + + @typedoc "The status of a queued email." + @type t() :: %__MODULE__{ + id: String.t(), + status: String.t(), + created_at: DateTime.t(), + status_at: DateTime.t(), + reason: String.t() | nil + } + + @enforce_keys [:id, :status, :created_at, :status_at] + defstruct [:id, :status, :created_at, :status_at, :reason] +end diff --git a/lib/posthog/registry.ex b/lib/posthog/registry.ex index 90e22cd..a634db5 100644 --- a/lib/posthog/registry.ex +++ b/lib/posthog/registry.ex @@ -15,6 +15,16 @@ defmodule PostHog.Registry do ArgumentError -> disabled_config(supervisor_name) end + # The secret key stays out of `config/1`, so it never shows up where the config is read or logged. + def secret_key(supervisor_name) do + case Registry.meta(registry_name(supervisor_name), :secret_key) do + {:ok, secret_key} -> secret_key + :error -> nil + end + rescue + ArgumentError -> nil + end + def disabled_config(supervisor_name) do %{ supervisor_name: supervisor_name, diff --git a/lib/posthog/supervisor.ex b/lib/posthog/supervisor.ex index 47bc267..d34fe7d 100644 --- a/lib/posthog/supervisor.ex +++ b/lib/posthog/supervisor.ex @@ -59,7 +59,7 @@ defmodule PostHog.Supervisor do {Registry, keys: :unique, name: PostHog.Registry.registry_name(config.supervisor_name), - meta: [config: public_config]}, + meta: [config: public_config, secret_key: config.secret_key]}, {PostHog.FeatureFlags.CalledCache, supervisor_name: config.supervisor_name} ] ++ definition_loader(config, callers) ++ senders(config) ++ sources(config) diff --git a/mix.exs b/mix.exs index d4590f8..7b1fc95 100644 --- a/mix.exs +++ b/mix.exs @@ -54,6 +54,13 @@ defmodule PostHog.MixProject do "CONTRIBUTING.md" ], groups_for_modules: [ + Emails: [ + PostHog.Emails, + PostHog.Emails.Error, + PostHog.Emails.SendResult, + PostHog.Emails.Status, + PostHog.Emails.Warning + ], Integrations: [PostHog.Integrations.Plug, PostHog.Integrations.LLMAnalytics.Req], Testing: [PostHog.Test] ], diff --git a/public_api.snapshot b/public_api.snapshot index 2c3302c..2e07021 100644 --- a/public_api.snapshot +++ b/public_api.snapshot @@ -105,6 +105,44 @@ PostHog.Config macros: (none) +PostHog.Emails + module_doc: documented + functions: + get/1 doc: none + get/2 doc: documented + send/1 doc: none + send/2 doc: documented + macros: + (none) + +PostHog.Emails.Error + module_doc: documented + functions: + new/2 doc: none + macros: + (none) + +PostHog.Emails.SendResult + module_doc: documented + functions: + (none) + macros: + (none) + +PostHog.Emails.Status + module_doc: documented + functions: + (none) + macros: + (none) + +PostHog.Emails.Warning + module_doc: documented + functions: + (none) + macros: + (none) + PostHog.Error module_doc: documented functions: diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs new file mode 100644 index 0000000..6abfd6f --- /dev/null +++ b/test/posthog/emails_test.exs @@ -0,0 +1,746 @@ +defmodule PostHog.EmailsTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + import Mox + + alias PostHog.API + alias PostHog.Emails + alias PostHog.Emails.Error + + setup :verify_on_exit! + + @send_url "https://us.posthog.com/api/projects/@current/messaging/send/email/" + @template_id "0190F6D2-7C1E-7B3A-9F5E-1C2D3E4F5A6B" + @message_id "01a18356-c56c-0001-e494-7666bc915188" + + setup context do + Mox.stub_with(API.Mock, API.Stub) + test_pid = self() + + config = + [ + api_host: "https://us.i.posthog.com", + api_key: "phc_project_key", + secret_key: " phs_secret_key ", + api_client_module: API.Mock, + supervisor_name: context.test, + enable_local_evaluation: false + ] + |> Keyword.merge(context[:config] || []) + |> PostHog.Config.validate!() + |> Map.put(:email_sleep, fn ms -> send(test_pid, {:slept, ms}) end) + + start_link_supervised!({PostHog.Supervisor, config}) + %{name: context.test} + end + + defp expect_reply(times \\ 1, reply) do + test_pid = self() + + expect(API.Mock, :request, times, fn _client, method, url, opts -> + send(test_pid, {:request, method, url, opts}) + reply + end) + end + + defp accepted(body \\ %{"message_id" => @message_id}), + do: {:ok, %{status: 202, headers: %{}, body: body}} + + defp refused(status, body, headers \\ %{}), + do: {:ok, %{status: status, headers: headers, body: body}} + + defp sent_body do + assert_received {:request, :post, @send_url, opts} + Jason.decode!(opts[:body]) + end + + defp header(opts, name) do + Enum.find_value(opts[:headers], fn {key, value} -> if key == name, do: value end) + end + + defp refute_requested, do: refute_received({:request, _, _, _}) + + describe "send/2 with a template made in PostHog" do + test "posts the flat template body to the app host", %{name: name} do + expect_reply(accepted()) + + assert {:ok, %Emails.SendResult{id: @message_id, replayed: false, warnings: []}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + template_data: %{name: "Ada"}, + distinct_id: "user_123" + ) + + assert sent_body() == %{ + "template_id" => "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", + "to" => "user@example.com", + "message_data" => %{"name" => "Ada"}, + "distinct_id" => "user_123" + } + end + + test "sends empty message data and no distinct ID when left out", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert sent_body() == %{ + "template_id" => "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", + "to" => "user@example.com", + "message_data" => %{} + } + end + + test "never takes the distinct ID from the process context", %{name: name} do + PostHog.set_context(name, %{distinct_id: "context_user"}) + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + refute Map.has_key?(sent_body(), "distinct_id") + end + + test "refuses a code form field", %{name: name} do + assert {:error, + %Error{code: "invalid_form", field: "subject", name: :validation_error} = error} = + Emails.send(name, to: "user@example.com", template_id: @template_id, subject: "Hi") + + assert error.fix =~ "template_key" + assert error.why =~ "template made in PostHog" + refute_requested() + end + + test "refuses a template ID that is not a UUID", %{name: name} do + assert {:error, %Error{code: "invalid_template_id", field: "template_id"}} = + Emails.send(name, to: "user@example.com", template_id: "welcome") + + refute_requested() + end + + test "refuses template data that is not a map", %{name: name} do + assert {:error, %Error{code: "invalid_template_data"}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + template_data: [name: "Ada"] + ) + + refute_requested() + end + + test "refuses template data that can't be encoded as JSON", %{name: name} do + assert {:error, %Error{code: "invalid_template_data"}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + template_data: %{pid: self()} + ) + + refute_requested() + end + end + + describe "send/2 with content from code" do + test "posts the rendered content with engagement capture on by default", %{name: name} do + expect_reply(accepted()) + + assert {:ok, %Emails.SendResult{id: @message_id}} = + Emails.send(name, + to: "user@example.com", + distinct_id: "user_123", + template_key: "password-reset", + subject: "Reset your password", + html: "

Reset

", + text: "Reset", + from: "Acme ", + reply_to: "support@example.com" + ) + + assert sent_body() == %{ + "to" => "user@example.com", + "distinct_id" => "user_123", + "template_key" => "password-reset", + "subject" => "Reset your password", + "html" => "

Reset

", + "text" => "Reset", + "from" => "Acme ", + "reply_to" => ["support@example.com"], + "capture_engagement" => true + } + end + + test "sends only the fields given, and capture_engagement false", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = + Emails.send(name, + to: "user@example.com", + template_key: "receipt", + subject: "Your receipt", + text: "Thanks", + reply_to: ["a@example.com", "b@example.com"], + capture_engagement: false, + html: nil + ) + + assert sent_body() == %{ + "to" => "user@example.com", + "template_key" => "receipt", + "subject" => "Your receipt", + "text" => "Thanks", + "reply_to" => ["a@example.com", "b@example.com"], + "capture_engagement" => false + } + end + + test "needs html, text or both", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "html"}} = + Emails.send(name, to: "user@example.com", template_key: "receipt", subject: "Hi") + + refute_requested() + end + + test "needs a subject", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "subject"}} = + Emails.send(name, to: "user@example.com", template_key: "receipt", html: "

") + + refute_requested() + end + + test "refuses template data on the code form", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "template_data"} = error} = + Emails.send(name, + to: "user@example.com", + template_key: "receipt", + subject: "Hi", + html: "

", + template_data: %{a: 1} + ) + + assert error.fix =~ "template_id" + refute_requested() + end + + test "refuses a template key with other characters", %{name: name} do + assert {:error, %Error{code: "invalid_template_key", name: :validation_error}} = + Emails.send(name, + to: "user@example.com", + template_key: "password reset", + subject: "Hi", + html: "

" + ) + + refute_requested() + end + + test "refuses a reply_to that is not addresses", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "reply_to"}} = + Emails.send(name, + to: "user@example.com", + template_key: "receipt", + subject: "Hi", + html: "

", + reply_to: [:nope] + ) + + refute_requested() + end + + test "refuses a capture_engagement that is not a boolean", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "capture_engagement"}} = + Emails.send(name, + to: "user@example.com", + template_key: "receipt", + subject: "Hi", + html: "

", + capture_engagement: "yes" + ) + + refute_requested() + end + end + + describe "send/2 argument checks" do + test "needs exactly one form", %{name: name} do + assert {:error, %Error{code: "invalid_form"}} = Emails.send(name, to: "user@example.com") + + assert {:error, %Error{code: "invalid_form"}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + template_key: "receipt" + ) + + refute_requested() + end + + test "refuses an option neither form takes", %{name: name} do + assert {:error, %Error{code: "invalid_form", field: "cc", fix: "Remove cc and send again."}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + cc: "x@example.com" + ) + + refute_requested() + end + + test "refuses options that are not a keyword list or map", %{name: name} do + assert {:error, %Error{code: "invalid_form"}} = Emails.send(name, ["user@example.com"]) + refute_requested() + end + + test "takes a map of options", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, %{to: "user@example.com", template_id: @template_id}) + end + + test "checks to, distinct_id, idempotency_key and max_retries", %{name: name} do + base = [to: "user@example.com", template_id: @template_id] + + for {opts, code} <- [ + {[to: " "], "invalid_recipient"}, + {[to: ["a@example.com"]], "invalid_recipient"}, + {[distinct_id: ""], "invalid_distinct_id"}, + {[idempotency_key: "has space"], "invalid_idempotency_key"}, + {[idempotency_key: String.duplicate("a", 256)], "invalid_idempotency_key"}, + {[max_retries: -1], "invalid_max_retries"}, + {[max_retries: 1.0], "invalid_max_retries"} + ] do + assert {:error, %Error{code: ^code, name: :validation_error}} = + Emails.send(name, Keyword.merge(base, opts)) + end + + refute_requested() + end + end + + describe "send/2 headers and hosts" do + test "authenticates with the trimmed secret key and sends the SDK headers", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + idempotency_key: "welcome:user_123" + ) + + assert_received {:request, :post, @send_url, opts} + assert header(opts, "authorization") == "Bearer phs_secret_key" + assert header(opts, "content-type") == "application/json" + assert header(opts, "accept") == "application/json" + assert header(opts, "idempotency-key") == "welcome:user_123" + assert header(opts, "user-agent") == PostHog.API.Client.user_agent() + assert header(opts, "x-posthog-migrated-from") == nil + assert opts[:retry] == false + assert opts[:redirect] == false + assert opts[:compress_body] == false + assert opts[:receive_timeout] == 15_000 + refute Keyword.has_key?(opts, :json) + end + + @tag config: [migrated_from: "resend", email_request_timeout_ms: 5_000] + test "sends the migration source and the request timeout", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:request, :post, @send_url, opts} + assert header(opts, "x-posthog-migrated-from") == "resend" + assert opts[:receive_timeout] == 5_000 + end + + @tag config: [api_host: "https://eu.i.posthog.com"] + test "maps an EU ingestion host to the EU app host", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:request, :post, + "https://eu.posthog.com/api/projects/@current/messaging/send/email/", _} + end + + @tag config: [api_host: "https://proxy.example.com"] + test "refuses a custom host without an app host", %{name: name} do + assert {:error, %Error{code: "missing_app_host", name: :application_error}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + refute_requested() + end + + @tag config: [ + api_host: "https://proxy.example.com", + app_host: " https://posthog.example.com/ " + ] + test "sends to the app host the client sets", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:request, :post, + "https://posthog.example.com/api/projects/@current/messaging/send/email/", + _} + end + + @tag config: [app_host: "https://eu.i.posthog.com"] + test "maps a cloud ingestion host set as the app host", %{name: name} do + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:request, :post, + "https://eu.posthog.com/api/projects/@current/messaging/send/email/", _} + end + + @tag config: [secret_key: nil] + test "refuses to send without a secret key", %{name: name} do + assert {:error, %Error{code: "missing_secret_key", name: :missing_api_key} = error} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert error.message =~ "hog_flow:write" + refute_requested() + end + + test "a client that is not running sends nothing" do + assert {:error, %Error{code: "client_disabled", name: :application_error}} = + Emails.send(:not_started, to: "user@example.com", template_id: @template_id) + + refute_requested() + end + + test "never puts the secret key or the body in an error", %{name: name} do + expect_reply(refused(400, %{"code" => "invalid_request", "detail" => "Bad field."})) + + assert {:error, error} = + Emails.send(name, + to: "user@example.com", + template_key: "k", + subject: "s", + html: "secret-html" + ) + + rendered = inspect(error) <> Exception.message(error) + refute rendered =~ "phs_secret_key" + refute rendered =~ "secret-html" + end + end + + describe "send/2 idempotency and retries" do + test "every attempt sends the same generated UUID v4 key", %{name: name} do + expect_reply(refused(503, %{"code" => "service_unavailable"})) + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:request, :post, _, first} + assert_received {:request, :post, _, second} + key = header(first, "idempotency-key") + assert key == header(second, "idempotency-key") + assert key =~ ~r/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + end + + test "makes 3 attempts by default and waits about 1 and then 2 seconds", %{name: name} do + expect_reply(3, refused(503, nil)) + + assert {:error, %Error{code: "api_error", status_code: 503, name: :service_unavailable}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:slept, first} + assert_received {:slept, second} + refute_received {:slept, _} + assert first in 800..1200 + assert second in 1600..2400 + end + + test "a per-send max_retries of 0 makes one attempt", %{name: name} do + expect_reply(refused(503, %{"code" => "service_unavailable"})) + + assert {:error, %Error{code: "service_unavailable"}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 0 + ) + + refute_received {:slept, _} + end + + @tag config: [email_request_max_retries: 0] + test "the client's email_request_max_retries sets the default", %{name: name} do + expect_reply(refused(503, nil)) + + assert {:error, %Error{status_code: 503}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + end + + test "retries network errors, throttling and an in-flight key", %{name: name} do + expect_reply({:error, %Req.TransportError{reason: :econnrefused}}) + expect_reply(refused(429, %{"code" => "throttled"})) + expect_reply(accepted()) + + assert {:ok, _} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 3 + ) + + expect_reply(refused(409, %{"code" => "idempotency_key_in_flight"})) + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + end + + test "a network error on the last attempt is service_unavailable", %{name: name} do + expect_reply({:error, %Req.TransportError{reason: :timeout}}) + + assert {:error, %Error{code: "network_error", name: :service_unavailable, status_code: nil}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 0 + ) + end + + test "does not retry a monthly quota, a conflict or a suppressed recipient", %{name: name} do + for {status, code, error_name} <- [ + {429, "quota_limited", :monthly_quota_exceeded}, + {409, "template_outdated", :validation_error}, + {422, "recipient_suppressed", :validation_error}, + {422, "idempotency_key_reused", :invalid_idempotent_request}, + {400, "invalid_recipient", :validation_error}, + {401, "authentication_failed", :missing_api_key}, + {403, "permission_denied", :invalid_permission}, + {404, "not_found", :not_found}, + {500, "server_error", :application_error} + ] do + expect_reply(refused(status, %{"code" => code})) + + assert {:error, %Error{code: ^code, name: ^error_name, status_code: ^status}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + end + + refute_received {:slept, _} + end + + test "an in-flight key that stays in flight is concurrent_idempotent_requests", %{name: name} do + expect_reply(refused(409, %{"code" => "idempotency_key_in_flight"})) + + assert {:error, %Error{name: :concurrent_idempotent_requests}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 0 + ) + end + + test "waits for a Retry-After longer than the backoff", %{name: name} do + expect_reply(refused(429, %{"code" => "throttled"}, %{"retry-after" => ["7"]})) + expect_reply(accepted()) + + assert {:ok, _} = Emails.send(name, to: "user@example.com", template_id: @template_id) + assert_received {:slept, 7_000} + end + + test "reads a Retry-After date", %{name: name} do + later = + DateTime.utc_now() + |> DateTime.add(20) + |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + + expect_reply(refused(503, %{"code" => "service_unavailable"}, [{"retry-after", later}])) + + assert {:error, %Error{retry_after_ms: wait}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 0 + ) + + assert wait in 18_000..20_000 + end + + test "fails at once on a Retry-After over 30 seconds", %{name: name} do + expect_reply(refused(429, %{"code" => "throttled"}, %{"retry-after" => ["60"]})) + + assert {:error, + %Error{code: "throttled", retry_after_ms: 60_000, name: :rate_limit_exceeded}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + refute_received {:slept, _} + end + + test "an acceptance without a message ID is api_error and not retried", %{name: name} do + expect_reply(refused(202, %{})) + + assert {:error, %Error{code: "api_error", status_code: 202}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + refute_received {:slept, _} + end + + @tag capture_log: true + test "a client module that raises is unexpected_error", %{name: name} do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> raise "boom" end) + + assert {:error, %Error{code: "unexpected_error", name: :application_error}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + end + end + + describe "send/2 replies" do + test "keeps every field of a refusal", %{name: name} do + expect_reply( + refused(422, %{ + "type" => "validation_error", + "code" => "recipient_suppressed", + "detail" => "Nope.", + "attr" => "to", + "why" => "The address bounced.", + "fix" => "Send to another address.", + "docs_url" => "https://posthog.com/docs" + }) + ) + + assert {:error, error} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert %Error{ + name: :validation_error, + code: "recipient_suppressed", + message: "Nope.", + status_code: 422, + field: "to", + why: "The address bounced.", + fix: "Send to another address.", + docs_url: "https://posthog.com/docs" + } = error + end + + test "reads a JSON body the client module did not decode", %{name: name} do + expect_reply(refused(404, ~s({"code": "not_found", "detail": "No template."}))) + + assert {:error, %Error{code: "not_found", message: "No template."}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + end + + test "returns a replay and logs each warning", %{name: name} do + expect_reply( + accepted(%{ + "message_id" => @message_id, + "replayed" => true, + "warnings" => [ + %{"code" => "no_text", "detail" => "No text part.", "fix" => "Add text."} + ] + }) + ) + + log = + capture_log(fn -> + assert {:ok, %Emails.SendResult{replayed: true, warnings: [warning]}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert warning == %Emails.Warning{ + code: "no_text", + message: "No text part.", + fix: "Add text." + } + end) + + assert log =~ "PostHog email warning no_text: No text part." + end + end + + describe "get/2" do + test "reads the status of a sent email", %{name: name} do + expect_reply( + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "id" => @message_id, + "status" => "bounced", + "created_at" => "2026-10-28T16:27:44.364000Z", + "status_at" => "2026-10-28T16:30:02.100000Z", + "reason" => "hard" + } + }} + ) + + assert {:ok, %Emails.Status{} = status} = Emails.get(name, String.upcase(@message_id)) + + assert status.id == @message_id + assert status.status == "bounced" + assert status.reason == "hard" + assert status.created_at == ~U[2026-10-28 16:27:44.364000Z] + assert status.status_at == ~U[2026-10-28 16:30:02.100000Z] + + assert_received {:request, :get, + "https://us.posthog.com/api/projects/@current/messaging/emails/01a18356-c56c-0001-e494-7666bc915188/", + opts} + + assert header(opts, "authorization") == "Bearer phs_secret_key" + assert header(opts, "idempotency-key") == nil + refute Keyword.has_key?(opts, :body) + end + + test "passes an unknown status through", %{name: name} do + expect_reply( + {:ok, + %{ + status: 200, + body: %{ + "id" => @message_id, + "status" => "archived", + "created_at" => "2026-10-28T16:27:44Z", + "status_at" => "2026-10-28T16:27:44Z" + } + }} + ) + + assert {:ok, %Emails.Status{status: "archived", reason: nil}} = + Emails.get(name, @message_id) + end + + test "a record without its times is api_error", %{name: name} do + expect_reply({:ok, %{status: 200, body: %{"id" => @message_id, "status" => "sent"}}}) + + assert {:error, %Error{code: "api_error", status_code: 200}} = Emails.get(name, @message_id) + end + + test "does not retry an unknown ID", %{name: name} do + expect_reply(refused(404, %{"code" => "not_found"})) + + assert {:error, %Error{code: "not_found", name: :not_found}} = Emails.get(name, @message_id) + refute_received {:slept, _} + end + + test "refuses an ID that is not a UUID", %{name: name} do + assert {:error, %Error{code: "invalid_message_id", name: :validation_error}} = + Emails.get(name, "msg_1") + + refute_requested() + end + end + + describe "the default instance" do + test "send/1 and get/1 use the PostHog instance" do + # The test config doesn't start the PostHog instance. + assert {:error, %Error{code: "client_disabled"}} = + Emails.send(to: "user@example.com", template_id: @template_id) + + assert {:error, %Error{code: "invalid_message_id"}} = Emails.get("nope") + end + end + + describe "config" do + test "validates migrated_from as a header token" do + assert {:error, %NimbleOptions.ValidationError{}} = + PostHog.Config.validate(api_key: "phc", migrated_from: "re send") + end + end +end From dc04ec9d19fdb3ccee6537263a934397d6e7e3d8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:00:39 +0000 Subject: [PATCH 02/13] fix(emails): bound each request, parse replies in the SDK, require an absolute app_host - Turn off Req's body decoding, so a 2xx with malformed JSON is an api_error with its status instead of a retried network_error. - Run each request in a task stopped at email_request_timeout_ms, so the timeout bounds the whole request, not only each socket read. - Validate app_host as an absolute http(s) URL, so a relative value can't resolve against the ingestion host and send it the secret key. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/config_schema.ex | 16 +++++- lib/posthog/emails.ex | 32 +++++++++++- test/posthog/emails_test.exs | 94 +++++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/lib/posthog/config_schema.ex b/lib/posthog/config_schema.ex index 0889281..dcd0814 100644 --- a/lib/posthog/config_schema.ex +++ b/lib/posthog/config_schema.ex @@ -51,7 +51,7 @@ defmodule PostHog.Config.Schema do """ ], app_host: [ - type: {:or, [:string, nil]}, + type: {:or, [{:custom, __MODULE__, :http_url, []}, nil]}, default: nil, doc: """ The PostHog app host that `PostHog.Emails` sends to, such as `https://posthog.example.com` @@ -185,6 +185,20 @@ defmodule PostHog.Config.Schema do ] ++ shared() end + @doc false + def http_url(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and host not in [nil, ""] -> + {:ok, value} + + _other -> + {:error, "expected an absolute http or https URL, got: #{inspect(value)}"} + end + end + + def http_url(value), do: {:error, "expected a string, got: #{inspect(value)}"} + @doc false def header_token(value) when is_binary(value) do if value =~ ~r/\A[\x21-\x7e]+\z/ do diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index f0b6d3b..dd4fe1d 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -79,7 +79,8 @@ defmodule PostHog.Emails do 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 run in the calling process. The SDK keeps no email queue. + Sends and status reads block the calling process until they finish. The SDK keeps no email + queue. ## Errors @@ -616,18 +617,22 @@ defmodule PostHog.Emails do # Requests defp request(connection, method, path, headers, body) do + # The SDK parses the body itself, so a malformed acceptance stays an api_error with its status. options = [ headers: headers, retry: false, redirect: false, compress_body: false, + decode_body: false, receive_timeout: connection.timeout_ms ] ++ if(body, do: [body: body], else: []) %{module: module, client: client} = connection.api_client - case module.request(client, method, connection.app_host <> path, options) do + case within_deadline(connection.timeout_ms, fn -> + module.request(client, method, connection.app_host <> path, options) + end) do {:ok, %{status: status} = response} when is_integer(status) -> {:ok, status, fields(Map.get(response, :body)), retry_after_ms(Map.get(response, :headers))} @@ -637,6 +642,29 @@ defmodule PostHog.Emails do end end + # The client's own timeouts bound each phase, not the whole request, so the request runs in a + # task that is stopped at the deadline. Failures inside it come back to the caller. + defp within_deadline(timeout_ms, fun) do + task = + Task.async(fn -> + try do + {:returned, fun.()} + rescue + exception -> {:raised, exception, __STACKTRACE__} + catch + kind, reason -> {:caught, kind, reason, __STACKTRACE__} + end + end) + + case Task.yield(task, timeout_ms) || Task.shutdown(task, :brutal_kill) do + {:ok, {:returned, result}} -> result + {:ok, {:raised, exception, stacktrace}} -> reraise exception, stacktrace + {:ok, {:caught, kind, reason, stacktrace}} -> :erlang.raise(kind, reason, stacktrace) + # Stopped at the deadline, or ended without a reply. + _timeout_or_exit -> {:error, :timeout} + end + end + defp fields(body) when is_map(body), do: body defp fields(body) when is_binary(body) do diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 6abfd6f..9680672 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -1,3 +1,20 @@ +defmodule PostHog.EmailsTest.ReqTestClient do + @moduledoc false + # The default client, with Req.Test in place of the network. + @behaviour PostHog.API.Client + + alias PostHog.API.Client + + @impl true + def client(api_key, api_host) do + default = Client.client(api_key, api_host) + %{default | client: Req.merge(default.client, plug: {Req.Test, __MODULE__})} + end + + @impl true + defdelegate request(client, method, url, opts), to: Client +end + defmodule PostHog.EmailsTest do use ExUnit.Case, async: true @@ -333,7 +350,7 @@ defmodule PostHog.EmailsTest do assert header(opts, "content-type") == "application/json" assert header(opts, "accept") == "application/json" assert header(opts, "idempotency-key") == "welcome:user_123" - assert header(opts, "user-agent") == PostHog.API.Client.user_agent() + assert header(opts, "user-agent") == API.Client.user_agent() assert header(opts, "x-posthog-migrated-from") == nil assert opts[:retry] == false assert opts[:redirect] == false @@ -742,5 +759,80 @@ defmodule PostHog.EmailsTest do assert {:error, %NimbleOptions.ValidationError{}} = PostHog.Config.validate(api_key: "phc", migrated_from: "re send") end + + test "refuses an app_host that is not an absolute HTTP(S) URL" do + for app_host <- ["posthog.example.com", "/", "ftp://posthog.example.com", "https://"] do + assert {:error, %NimbleOptions.ValidationError{key: :app_host}} = + PostHog.Config.validate(api_key: "phc", app_host: app_host) + end + + assert {:ok, %{app_host: "http://localhost:8010"}} = + PostHog.Config.validate(api_key: "phc", app_host: " http://localhost:8010 ") + end + end + + describe "through the default Req client" do + @describetag config: [api_client_module: PostHog.EmailsTest.ReqTestClient] + + test "an acceptance with malformed JSON is api_error with its status, not retried", %{ + name: name + } do + Req.Test.expect(__MODULE__.ReqTestClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(202, "{not json") + end) + + assert {:error, %Error{code: "api_error", status_code: 202}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + refute_received {:slept, _} + end + + test "sends the body as is, without the project API key", %{name: name} do + test_pid = self() + + Req.Test.expect(__MODULE__.ReqTestClient, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test_pid, {:raw, conn.method, conn.host, conn.request_path, conn.req_headers, body}) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(202, ~s({"message_id": "#{@message_id}"})) + end) + + assert {:ok, %Emails.SendResult{id: @message_id}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:raw, "POST", "us.posthog.com", + "/api/projects/@current/messaging/send/email/", headers, body} + + refute Map.has_key?(Jason.decode!(body), "api_key") + assert {"content-encoding", "gzip"} not in headers + + assert Enum.count(headers, fn {name, _value} -> name == "user-agent" end) == 1 + end + + @tag config: [ + api_client_module: PostHog.EmailsTest.ReqTestClient, + email_request_timeout_ms: 50 + ] + test "a request that outlasts the timeout is a network error at the deadline", %{name: name} do + Req.Test.expect(__MODULE__.ReqTestClient, fn conn -> + Process.sleep(2_000) + Plug.Conn.send_resp(conn, 202, "{}") + end) + + started = System.monotonic_time(:millisecond) + + assert {:error, %Error{code: "network_error", name: :service_unavailable}} = + Emails.send(name, + to: "user@example.com", + template_id: @template_id, + max_retries: 0 + ) + + assert System.monotonic_time(:millisecond) - started < 1_000 + end end end From 9a35f9c2e41122ebc077eba2ca740ecc9e5c397a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:04:18 +0000 Subject: [PATCH 03/13] fix(emails): run each request in a monitored worker, not a linked task A linked Task left {:EXIT, pid, :normal} in the mailbox of a caller that traps exits, which crashes a GenServer without a matching handle_info/2. The worker is now spawned with a monitor only, and the deadline path waits for its DOWN message and drops a late reply, so the caller's mailbox stays clean after a reply and after a timeout. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.ex | 61 ++++++++++++++++++++++++++---------- test/posthog/emails_test.exs | 28 +++++++++++++++++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index dd4fe1d..efcdf0c 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -643,28 +643,57 @@ defmodule PostHog.Emails do end # The client's own timeouts bound each phase, not the whole request, so the request runs in a - # task that is stopped at the deadline. Failures inside it come back to the caller. + # worker that is killed at the deadline. The worker is monitored, not linked, so a caller that + # traps exits gets no exit message. Failures inside it come back to the caller. defp within_deadline(timeout_ms, fun) do - task = - Task.async(fn -> - try do - {:returned, fun.()} - rescue - exception -> {:raised, exception, __STACKTRACE__} - catch - kind, reason -> {:caught, kind, reason, __STACKTRACE__} - end + caller = self() + reply = make_ref() + # Keeps test allowances, such as Mox's, for the worker. + callers = [caller | Process.get(:"$callers", [])] + + {worker, monitor} = + spawn_monitor(fn -> + Process.put(:"$callers", callers) + Kernel.send(caller, {reply, run_capturing(fun)}) end) - case Task.yield(task, timeout_ms) || Task.shutdown(task, :brutal_kill) do - {:ok, {:returned, result}} -> result - {:ok, {:raised, exception, stacktrace}} -> reraise exception, stacktrace - {:ok, {:caught, kind, reason, stacktrace}} -> :erlang.raise(kind, reason, stacktrace) - # Stopped at the deadline, or ended without a reply. - _timeout_or_exit -> {:error, :timeout} + receive do + {^reply, result} -> + Process.demonitor(monitor, [:flush]) + unwrap(result) + + {:DOWN, ^monitor, :process, ^worker, _reason} -> + {:error, :worker_down} + after + timeout_ms -> + Process.exit(worker, :kill) + # The DOWN message comes after any reply the worker sent, so both leave the mailbox here. + receive do + {:DOWN, ^monitor, :process, ^worker, _reason} -> :ok + end + + receive do + {^reply, _late} -> :ok + after + 0 -> :ok + end + + {:error, :timeout} end end + defp run_capturing(fun) do + {:returned, fun.()} + rescue + exception -> {:raised, exception, __STACKTRACE__} + catch + kind, reason -> {:caught, kind, reason, __STACKTRACE__} + end + + defp unwrap({:returned, result}), do: result + defp unwrap({:raised, exception, stacktrace}), do: reraise(exception, stacktrace) + defp unwrap({:caught, kind, reason, stacktrace}), do: :erlang.raise(kind, reason, stacktrace) + defp fields(body) when is_map(body), do: body defp fields(body) when is_binary(body) do diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 9680672..ee29153 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -608,6 +608,34 @@ defmodule PostHog.EmailsTest do end end + describe "send/2 in a caller that traps exits" do + @tag config: [email_request_timeout_ms: 50] + test "leaves the caller's mailbox clean after a reply and after a timeout", %{name: name} do + expect_reply(accepted()) + + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + Process.sleep(1_000) + accepted() + end) + + task = + Task.async(fn -> + Process.flag(:trap_exit, true) + sent = Emails.send(name, to: "user@example.com", template_id: @template_id) + + timed_out = + Emails.send(name, to: "user@example.com", template_id: @template_id, max_retries: 0) + + # Give any late exit or reply message time to arrive. + Process.sleep(100) + {sent, timed_out, Process.info(self(), :messages)} + end) + + assert {{:ok, _}, {:error, %Error{code: "network_error"}}, {:messages, []}} = + Task.await(task) + end + end + describe "send/2 replies" do test "keeps every field of a refusal", %{name: name} do expect_reply( From a04a561538f8381a3c6e4db346e89c396e203ef0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:08:34 +0000 Subject: [PATCH 04/13] fix(emails): stop the request worker when its caller dies The unlinked worker outlived a caller killed mid-request, such as by a job timeout, so the request could still go out after cancellation. The worker is now spawned linked and monitored in one step. It unlinks before it replies, and the caller unlinks before it kills the worker at the deadline, so a caller that traps exits still gets no exit message. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.ex | 19 +++++++++++++------ test/posthog/emails_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index efcdf0c..c4fe322 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -643,8 +643,9 @@ defmodule PostHog.Emails do end # The client's own timeouts bound each phase, not the whole request, so the request runs in a - # worker that is killed at the deadline. The worker is monitored, not linked, so a caller that - # traps exits gets no exit message. Failures inside it come back to the caller. + # worker that is killed at the deadline. The worker is linked, so it dies with a caller that is + # killed or shut down mid-request, and monitored. Each side unlinks before it ends the worker's + # life, so a caller that traps exits gets no exit message. Failures come back to the caller. defp within_deadline(timeout_ms, fun) do caller = self() reply = make_ref() @@ -652,10 +653,15 @@ defmodule PostHog.Emails do callers = [caller | Process.get(:"$callers", [])] {worker, monitor} = - spawn_monitor(fn -> - Process.put(:"$callers", callers) - Kernel.send(caller, {reply, run_capturing(fun)}) - end) + :erlang.spawn_opt( + fn -> + Process.put(:"$callers", callers) + result = run_capturing(fun) + Process.unlink(caller) + Kernel.send(caller, {reply, result}) + end, + [:link, :monitor] + ) receive do {^reply, result} -> @@ -666,6 +672,7 @@ defmodule PostHog.Emails do {:error, :worker_down} after timeout_ms -> + Process.unlink(worker) Process.exit(worker, :kill) # The DOWN message comes after any reply the worker sent, so both leave the mailbox here. receive do diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index ee29153..2c7b03f 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -636,6 +636,33 @@ defmodule PostHog.EmailsTest do end end + describe "send/2 when its caller dies" do + @tag config: [email_request_timeout_ms: 5_000] + test "the request stops with the caller", %{name: name} do + test_pid = self() + + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + send(test_pid, {:requesting, self()}) + Process.sleep(500) + send(test_pid, :request_finished) + accepted() + end) + + caller = + spawn(fn -> + Process.put(:"$callers", [test_pid]) + Emails.send(name, to: "user@example.com", template_id: @template_id) + end) + + assert_receive {:requesting, worker} + worker_ref = Process.monitor(worker) + Process.exit(caller, :kill) + + assert_receive {:DOWN, ^worker_ref, :process, ^worker, _reason} + refute_receive :request_finished, 700 + end + end + describe "send/2 replies" do test "keeps every field of a refusal", %{name: name} do expect_reply( From 63923636cfdb50f13ad1436ac8992a0f14272545 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:13:30 +0000 Subject: [PATCH 05/13] fix(emails): keep request worker failures out of the caller A linked worker killed its caller when it crashed. The worker is now only monitored, so its death returns network_error, and a watchdog kills it when the caller dies. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.ex | 33 ++++++++++++++++++++------------- test/posthog/emails_test.exs | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index c4fe322..336668f 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -643,9 +643,9 @@ defmodule PostHog.Emails do end # The client's own timeouts bound each phase, not the whole request, so the request runs in a - # worker that is killed at the deadline. The worker is linked, so it dies with a caller that is - # killed or shut down mid-request, and monitored. Each side unlinks before it ends the worker's - # life, so a caller that traps exits gets no exit message. Failures come back to the caller. + # monitored worker that is killed at the deadline. The worker is not linked, so its failure comes + # back as an error rather than killing the caller, and a caller that traps exits gets no exit + # message. A watchdog kills the worker when the caller dies, so the request ends with its caller. defp within_deadline(timeout_ms, fun) do caller = self() reply = make_ref() @@ -653,15 +653,12 @@ defmodule PostHog.Emails do callers = [caller | Process.get(:"$callers", [])] {worker, monitor} = - :erlang.spawn_opt( - fn -> - Process.put(:"$callers", callers) - result = run_capturing(fun) - Process.unlink(caller) - Kernel.send(caller, {reply, result}) - end, - [:link, :monitor] - ) + spawn_monitor(fn -> + worker = self() + spawn(fn -> watch(caller, worker) end) + Process.put(:"$callers", callers) + Kernel.send(caller, {reply, run_capturing(fun)}) + end) receive do {^reply, result} -> @@ -672,7 +669,6 @@ defmodule PostHog.Emails do {:error, :worker_down} after timeout_ms -> - Process.unlink(worker) Process.exit(worker, :kill) # The DOWN message comes after any reply the worker sent, so both leave the mailbox here. receive do @@ -689,6 +685,17 @@ defmodule PostHog.Emails do end end + # Monitoring a caller that is already gone delivers DOWN at once, so no window is missed. + defp watch(caller, worker) do + caller_monitor = Process.monitor(caller) + worker_monitor = Process.monitor(worker) + + receive do + {:DOWN, ^caller_monitor, :process, ^caller, _reason} -> Process.exit(worker, :kill) + {:DOWN, ^worker_monitor, :process, ^worker, _reason} -> :ok + end + end + defp run_capturing(fun) do {:returned, fun.()} rescue diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 2c7b03f..62de30d 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -663,6 +663,25 @@ defmodule PostHog.EmailsTest do end end + describe "send/2 when its request worker dies" do + test "returns network_error and leaves the caller running", %{name: name} do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + Process.exit(self(), :kill) + end) + + task = + Task.async(fn -> + result = + Emails.send(name, to: "user@example.com", template_id: @template_id, max_retries: 0) + + Process.sleep(50) + {result, Process.info(self(), :messages)} + end) + + assert {{:error, %Error{code: "network_error"}}, {:messages, []}} = Task.await(task) + end + end + describe "send/2 replies" do test "keeps every field of a refusal", %{name: name} do expect_reply( From 2244ae8d271ea34510d92e3a5a2980dfa82ffc44 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:37:38 +0000 Subject: [PATCH 06/13] test(emails): run the default-instance test in the PostHog group Other async modules start the default PostHog instance, so asserting it was not running flaked on CI. The test now starts the instance in the PostHog group and checks that send/1 and get/1 reach it. Co-Authored-By: Claude Opus 5.5 --- test/posthog/emails_default_instance_test.exs | 53 +++++++++++++++++++ test/posthog/emails_test.exs | 10 ---- 2 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 test/posthog/emails_default_instance_test.exs diff --git a/test/posthog/emails_default_instance_test.exs b/test/posthog/emails_default_instance_test.exs new file mode 100644 index 0000000..efc8777 --- /dev/null +++ b/test/posthog/emails_default_instance_test.exs @@ -0,0 +1,53 @@ +defmodule PostHog.EmailsDefaultInstanceTest do + # Other modules start the default PostHog instance too, so this one runs in their group. + use PostHog.Case, + async: Version.match?(System.version(), ">= 1.18.0"), + group: PostHog + + @moduletag config: [supervisor_name: PostHog, secret_key: "phs_secret_key"] + + import Mox + + alias PostHog.Emails + + setup :setup_supervisor + setup :verify_on_exit! + + @message_id "01a18356-c56c-0001-e494-7666bc915188" + + test "send/1 and get/1 use the PostHog instance" do + expect(PostHog.API.Mock, :request, fn _client, :post, url, opts -> + assert url == "https://us.posthog.com/api/projects/@current/messaging/send/email/" + assert {"authorization", "Bearer phs_secret_key"} in opts[:headers] + {:ok, %{status: 202, headers: %{}, body: %{"message_id" => @message_id}}} + end) + + expect(PostHog.API.Mock, :request, fn _client, :get, url, _opts -> + assert url == + "https://us.posthog.com/api/projects/@current/messaging/emails/#{@message_id}/" + + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "id" => @message_id, + "status" => "queued", + "created_at" => "2026-11-02T10:00:00Z", + "status_at" => "2026-11-02T10:00:00Z", + "reason" => nil + } + }} + end) + + assert {:ok, %Emails.SendResult{id: @message_id}} = + Emails.send( + to: "user@example.com", + template_key: "receipt", + subject: "Hi", + text: "Hi" + ) + + assert {:ok, %Emails.Status{id: @message_id, status: "queued"}} = Emails.get(@message_id) + end +end diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 62de30d..46578cc 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -818,16 +818,6 @@ defmodule PostHog.EmailsTest do end end - describe "the default instance" do - test "send/1 and get/1 use the PostHog instance" do - # The test config doesn't start the PostHog instance. - assert {:error, %Error{code: "client_disabled"}} = - Emails.send(to: "user@example.com", template_id: @template_id) - - assert {:error, %Error{code: "invalid_message_id"}} = Emails.get("nope") - end - end - describe "config" do test "validates migrated_from as a header token" do assert {:error, %NimbleOptions.ValidationError{}} = From f94951540bf901315b527d0993fdd7150eb17007 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:40:01 +0000 Subject: [PATCH 07/13] test(emails): keep the flag loader out of the default-instance test A secret key starts the flag definition loader, whose requests could take the mock's expectations. Co-Authored-By: Claude Opus 5.5 --- test/posthog/emails_default_instance_test.exs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/posthog/emails_default_instance_test.exs b/test/posthog/emails_default_instance_test.exs index efc8777..8678a61 100644 --- a/test/posthog/emails_default_instance_test.exs +++ b/test/posthog/emails_default_instance_test.exs @@ -4,7 +4,13 @@ defmodule PostHog.EmailsDefaultInstanceTest do async: Version.match?(System.version(), ">= 1.18.0"), group: PostHog - @moduletag config: [supervisor_name: PostHog, secret_key: "phs_secret_key"] + # A secret key would also start the flag definition loader, whose requests take the mock's + # expectations. + @moduletag config: [ + supervisor_name: PostHog, + secret_key: "phs_secret_key", + enable_local_evaluation: false + ] import Mox From cb165cc51f8c9da30ee1d522b6d5f80d1b3f5c51 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:16:46 +0000 Subject: [PATCH 08/13] fix(emails): retry pool checkout timeouts and stop retries after shutdown Finch raises when no pooled connection frees up in time, which skipped the retry loop as an unexpected error; it is now a retryable network error. A call waiting to retry now checks the PostHog instance first and returns client_closed, without a request, when the instance has stopped. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.ex | 38 ++++++++++++++++++++++++++++++++++-- lib/posthog/emails/error.ex | 2 +- test/posthog/emails_test.exs | 29 ++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index 336668f..95e8c1e 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -95,6 +95,8 @@ defmodule PostHog.Emails do 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. @@ -541,6 +543,8 @@ defmodule PostHog.Emails do {:ok, secret_key} <- secret_key(name, operation) do {:ok, %{ + name: name, + nothing_done: nothing_done, api_client: config.api_client, app_host: app_host, secret_key: secret_key, @@ -562,6 +566,20 @@ defmodule PostHog.Emails do )} end + defp check_running(%{name: name, nothing_done: nothing_done}) do + case PostHog.Registry.config(name) do + %{enabled: true} -> + :ok + + _stopped -> + {:error, + Error.new( + "client_closed", + "The PostHog instance #{inspect(name)} stopped before the retry, so #{nothing_done}." + )} + end + end + # A custom host is often an ingestion proxy, which must not receive the secret key. defp app_host(config) do case Map.get(config, :app_host) do @@ -631,7 +649,7 @@ defmodule PostHog.Emails do %{module: module, client: client} = connection.api_client case within_deadline(connection.timeout_ms, fn -> - module.request(client, method, connection.app_host <> path, options) + call_client(module, client, method, connection.app_host <> path, options) end) do {:ok, %{status: status} = response} when is_integer(status) -> {:ok, status, fields(Map.get(response, :body)), @@ -642,6 +660,16 @@ defmodule PostHog.Emails do end end + # Finch raises, rather than returns, when no pooled connection frees up in time. + defp call_client(module, client, method, url, options) do + module.request(client, method, url, options) + rescue + exception in RuntimeError -> + if String.starts_with?(exception.message, "Finch was unable to provide a connection"), + do: {:error, exception}, + else: reraise(exception, __STACKTRACE__) + end + # The client's own timeouts bound each phase, not the whole request, so the request runs in a # monitored worker that is killed at the deadline. The worker is not linked, so its failure comes # back as an error rather than killing the caller, and a caller that traps exits gets no exit @@ -731,11 +759,17 @@ defmodule PostHog.Emails do delay_ms -> connection.sleep.(delay_ms) - with_retries(connection, max_retries, attempt_once, attempt + 1) + retry(connection, max_retries, attempt_once, attempt + 1) end end end + defp retry(connection, max_retries, attempt_once, attempt) do + with :ok <- check_running(connection) do + with_retries(connection, max_retries, attempt_once, attempt) + end + end + # A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. defp retryable?(%Error{status_code: nil, code: code}), do: code == "network_error" defp retryable?(%Error{status_code: 409, code: code}), do: code == "idempotency_key_in_flight" diff --git a/lib/posthog/emails/error.ex b/lib/posthog/emails/error.ex index 7ecc600..2aaed8d 100644 --- a/lib/posthog/emails/error.ex +++ b/lib/posthog/emails/error.ex @@ -10,7 +10,7 @@ defmodule PostHog.Emails.Error do - `:name` - the category, one of `t:name/0`. - `:code` - PostHog's error code, such as `"quota_limited"` or `"recipient_suppressed"`, or an SDK code: `"missing_secret_key"`, `"missing_app_host"`, `"client_disabled"`, - `"network_error"`, `"api_error"` (an answer without a code), `"unexpected_error"`, or an + `"client_closed"`, `"network_error"`, `"api_error"` (an answer without a code), `"unexpected_error"`, or an `"invalid_*"` code for an option the SDK refused before any request. - `:message` - human-readable message. - `:status_code` - the HTTP status, when PostHog answered. diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 46578cc..e7eff71 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -46,7 +46,10 @@ defmodule PostHog.EmailsTest do ] |> Keyword.merge(context[:config] || []) |> PostHog.Config.validate!() - |> Map.put(:email_sleep, fn ms -> send(test_pid, {:slept, ms}) end) + |> Map.put(:email_sleep, fn ms -> + send(test_pid, {:slept, ms}) + if context[:stop_instance_on_sleep], do: stop_supervised!(context.test) + end) start_link_supervised!({PostHog.Supervisor, config}) %{name: context.test} @@ -599,6 +602,30 @@ defmodule PostHog.EmailsTest do refute_received {:slept, _} end + test "retries a connection pool checkout timeout as a network error", %{name: name} do + expect(API.Mock, :request, 2, fn _client, _method, _url, _opts -> + raise "Finch was unable to provide a connection within the timeout due to excess queuing" + end) + + expect_reply(accepted()) + + assert {:ok, %Emails.SendResult{id: @message_id}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:slept, _} + assert_received {:slept, _} + end + + @tag stop_instance_on_sleep: true + test "sends no retry after the instance stops", %{name: name} do + expect_reply(refused(503, %{"code" => "service_unavailable"})) + + assert {:error, %Error{code: "client_closed"}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:slept, _} + end + @tag capture_log: true test "a client module that raises is unexpected_error", %{name: name} do expect(API.Mock, :request, fn _client, _method, _url, _opts -> raise "boom" end) From da5e56c13998f6797bdc1852fdc2175cb10a7e3f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:19:23 +0000 Subject: [PATCH 09/13] docs(emails): say that secret_key also turns on flag polling to api_host Email requests never send the secret key to an ingestion proxy, but local flag evaluation fetches definitions from api_host with the same key. Tell users behind such a proxy to turn local evaluation off. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/config_schema.ex | 6 ++++-- lib/posthog/emails.ex | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/posthog/config_schema.ex b/lib/posthog/config_schema.ex index dcd0814..2dd02dd 100644 --- a/lib/posthog/config_schema.ex +++ b/lib/posthog/config_schema.ex @@ -56,8 +56,10 @@ defmodule PostHog.Config.Schema do doc: """ The PostHog app host that `PostHog.Emails` sends to, such as `https://posthog.example.com` for a self-hosted instance. Without it, a PostHog Cloud `api_host` maps to its region's app - host, and any other `api_host` makes email calls fail with `missing_app_host`, because a - reverse proxy for ingestion must not receive the secret key. + host, and any other `api_host` makes email calls fail with `missing_app_host`, so email + requests never send the secret key to a reverse proxy for ingestion. Local flag + evaluation still fetches definitions from `api_host` with `secret_key`; set + `enable_local_evaluation: false` when the proxy must not see the key. """ ], email_request_timeout_ms: [ diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index 95e8c1e..e844010 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -63,8 +63,12 @@ defmodule PostHog.Emails do 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 the proxy must not receive the secret key. Without it, a send returns - the error `missing_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 From 0b2555402f162cba1edc1586eed49a58e5f284e4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:56:39 +0000 Subject: [PATCH 10/13] fix(emails): return a client exit as an error, not a crash A client that exits inside the request worker, as Finch does when its pool restarts, came back to the caller as an exit and crashed it. It is now a retryable network_error, and a throw is unexpected_error. Also reuse UUIDv7's encode and decode, read the email timeout and retries straight from the validated config, and name the config helper's argument for what it takes. Co-Authored-By: Claude Opus 5.5 --- lib/posthog/config.ex | 8 ++++---- lib/posthog/emails.ex | 24 ++++++++++-------------- test/posthog/emails_test.exs | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/lib/posthog/config.ex b/lib/posthog/config.ex index 22f9971..bd651e8 100644 --- a/lib/posthog/config.ex +++ b/lib/posthog/config.ex @@ -205,14 +205,14 @@ defmodule PostHog.Config do defp normalize_api_key(nil), do: "" defp normalize_api_key(api_key), do: api_key - defp normalize_optional_string(secret_key) when is_binary(secret_key) do - case String.trim(secret_key) do + defp normalize_optional_string(value) when is_binary(value) do + case String.trim(value) do "" -> nil - value -> value + trimmed -> trimmed end end - defp normalize_optional_string(secret_key), do: secret_key + defp normalize_optional_string(value), do: value defp normalize_api_host(api_host) when is_binary(api_host) do api_host diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index e844010..1cb0a9c 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -132,8 +132,6 @@ defmodule PostHog.Emails do "https://eu.posthog.com" => @eu_app_host } @max_retry_delay_ms 30_000 - @default_timeout_ms 15_000 - @default_max_retries 2 @log_metadata [posthog_skip_capture: true] @shared_fields [:to, :distinct_id, :idempotency_key, :max_retries] @@ -514,10 +512,7 @@ defmodule PostHog.Emails do end end - defp uuid?(value), - do: - is_binary(value) and - value =~ ~r/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + defp uuid?(value), do: UUIDv7.decode(value) != :error defp nonempty?(value), do: is_binary(value) and value != "" defp nonblank?(value), do: is_binary(value) and String.trim(value) != "" @@ -527,11 +522,7 @@ defmodule PostHog.Emails do defp uuid4 do <> = :crypto.strong_rand_bytes(16) - <> - |> Base.encode16(case: :lower) - |> then(fn <> -> - Enum.join([p1, p2, p3, p4, p5], "-") - end) + UUIDv7.encode(<>) end # Connection @@ -553,8 +544,8 @@ defmodule PostHog.Emails do app_host: app_host, secret_key: secret_key, migrated_from: Map.get(config, :migrated_from), - timeout_ms: Map.get(config, :email_request_timeout_ms, @default_timeout_ms), - max_retries: Map.get(config, :email_request_max_retries, @default_max_retries), + timeout_ms: config.email_request_timeout_ms, + max_retries: config.email_request_max_retries, sleep: Map.get(config, :email_sleep, &Process.sleep/1) }} end @@ -738,7 +729,12 @@ defmodule PostHog.Emails do defp unwrap({:returned, result}), do: result defp unwrap({:raised, exception, stacktrace}), do: reraise(exception, stacktrace) - defp unwrap({:caught, kind, reason, stacktrace}), do: :erlang.raise(kind, reason, stacktrace) + # Finch exits when its pool goes away mid-request, such as during a restart, so an exit is + # a network failure. + defp unwrap({:caught, :exit, reason, _stacktrace}), do: {:error, {:exit, reason}} + + defp unwrap({:caught, :throw, value, stacktrace}), + do: :erlang.raise(:error, {:nocatch, value}, stacktrace) defp fields(body) when is_map(body), do: body diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index e7eff71..3adebdc 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -707,6 +707,32 @@ defmodule PostHog.EmailsTest do assert {{:error, %Error{code: "network_error"}}, {:messages, []}} = Task.await(task) end + + test "retries a client exit, such as a restarting pool, as a network error", %{name: name} do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + exit({:noproc, {GenServer, :call, [:pool, :checkout, 5_000]}}) + end) + + expect_reply(accepted()) + + assert {:ok, %Emails.SendResult{id: @message_id}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + + assert_received {:slept, _ms} + end + + test "returns a client exit on a status read as network_error", %{name: name} do + expect(API.Mock, :request, 3, fn _client, :get, _url, _opts -> exit(:normal) end) + + assert {:error, %Error{code: "network_error"}} = Emails.get(name, @message_id) + end + + test "returns a client throw as unexpected_error without crashing the caller", %{name: name} do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> throw(:boom) end) + + assert {:error, %Error{code: "unexpected_error"}} = + Emails.send(name, to: "user@example.com", template_id: @template_id) + end end describe "send/2 replies" do From 74dd12ee5aadfc00957a795896a21baa6b9d010d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:00:18 +0000 Subject: [PATCH 11/13] refactor(emails): name a client exit instead of commenting it Co-Authored-By: Claude Opus 5.5 --- lib/posthog/emails.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index 1cb0a9c..e02be29 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -729,9 +729,7 @@ defmodule PostHog.Emails do defp unwrap({:returned, result}), do: result defp unwrap({:raised, exception, stacktrace}), do: reraise(exception, stacktrace) - # Finch exits when its pool goes away mid-request, such as during a restart, so an exit is - # a network failure. - defp unwrap({:caught, :exit, reason, _stacktrace}), do: {:error, {:exit, reason}} + defp unwrap({:caught, :exit, reason, _stacktrace}), do: {:error, {:client_exit, reason}} defp unwrap({:caught, :throw, value, stacktrace}), do: :erlang.raise(:error, {:nocatch, value}, stacktrace) From f6eabaeeadca675619efde3a0fed98e9d54c2b32 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:44:38 +0000 Subject: [PATCH 12/13] fix(emails): name the transactional_email scopes for sends and status reads Co-Authored-By: Claude Opus 5.5 --- lib/posthog/config_schema.ex | 4 ++-- lib/posthog/emails.ex | 17 +++++++++-------- test/posthog/emails_test.exs | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/posthog/config_schema.ex b/lib/posthog/config_schema.ex index 2dd02dd..6db4b89 100644 --- a/lib/posthog/config_schema.ex +++ b/lib/posthog/config_schema.ex @@ -46,8 +46,8 @@ defmodule PostHog.Config.Schema do A privileged project secret (`phs_`) or appropriately scoped personal API key (`phx_`). Fetches local feature flag definitions directly from PostHog. A configured `flag_definition_cache_provider` can supply definitions without this key. - `PostHog.Emails` needs a project secret API key with the `hog_flow:write` scope to send - email, or `hog_flow:read` to read an email's status. + `PostHog.Emails` needs a project secret API key with the `transactional_email:write` + scope to send email, or `transactional_email:read` to read an email's status. """ ], app_host: [ diff --git a/lib/posthog/emails.ex b/lib/posthog/emails.ex index e02be29..86d862f 100644 --- a/lib/posthog/emails.ex +++ b/lib/posthog/emails.ex @@ -13,9 +13,10 @@ defmodule PostHog.Emails do 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 Workflow write access (`hog_flow:write`) - in your project settings. The project API key (`phc_...`) captures events but cannot send - email. + 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, @@ -94,7 +95,7 @@ defmodule PostHog.Emails do - `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 - `hog_flow:write`. + `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 @@ -231,8 +232,8 @@ defmodule PostHog.Emails do 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 `hog_flow:read` can read statuses - but not send. Network errors, rate limits and an unavailable service are retried. + 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 @@ -606,9 +607,9 @@ defmodule PostHog.Emails do message = if operation == :send, do: - "Sending email needs secret_key, a project secret API key with the hog_flow:write scope.", + "Sending email needs secret_key, a project secret API key with the transactional_email:write scope.", else: - "Reading an email status needs secret_key, a project secret API key with the hog_flow:read or hog_flow:write scope." + "Reading an email status needs secret_key, a project secret API key with the transactional_email:read or transactional_email:write scope." {:error, Error.new("missing_secret_key", message)} end diff --git a/test/posthog/emails_test.exs b/test/posthog/emails_test.exs index 3adebdc..b249e9c 100644 --- a/test/posthog/emails_test.exs +++ b/test/posthog/emails_test.exs @@ -420,7 +420,7 @@ defmodule PostHog.EmailsTest do assert {:error, %Error{code: "missing_secret_key", name: :missing_api_key} = error} = Emails.send(name, to: "user@example.com", template_id: @template_id) - assert error.message =~ "hog_flow:write" + assert error.message =~ "transactional_email:write scope" refute_requested() end From 1cdbd826267d60c850a892f967bcd8c1724dbb4f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:59:50 +0000 Subject: [PATCH 13/13] docs(emails): rewrite the release note for transactional email Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/emails-send.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.sampo/changesets/emails-send.md b/.sampo/changesets/emails-send.md index 176c978..cb0354f 100644 --- a/.sampo/changesets/emails-send.md +++ b/.sampo/changesets/emails-send.md @@ -2,4 +2,31 @@ hex/posthog: minor --- -Add `PostHog.Emails` to send transactional email and read its status. `PostHog.Emails.send/2` queues one email from a template made in PostHog (`template_id` and `template_data`) or from content your code rendered (`template_key`, `subject`, and `html` and/or `text`). `PostHog.Emails.get/2` reads what happened to it. Both need a project secret API key in `secret_key`. New config options: `app_host`, `email_request_timeout_ms`, `email_request_max_retries` and `migrated_from`. +The new `PostHog.Emails` sends transactional email from your Elixir code and reads each email's delivery status. + +```elixir +# config/runtime.exs +config :posthog, api_key: "phc_...", secret_key: "phs_..." + +{:ok, %PostHog.Emails.SendResult{id: message_id}} = + PostHog.Emails.send( + to: "user@example.com", + distinct_id: "user_123", + template_key: "password-reset", + subject: "Reset your password", + html: rendered_html + ) + +# "delivered", "bounced", ... +{:ok, %PostHog.Emails.Status{status: status}} = PostHog.Emails.get(message_id) +``` + +You can also send a template made in PostHog by its `template_id`. + +You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Set it as `secret_key` in your `:posthog` config. Set `app_host` to your PostHog app URL when you self-host PostHog or send through a reverse proxy. The SDK also loads feature flags with this key, so give it **Feature flags: Read** too, or it logs a warning each time it polls. + +PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too. + +A send with your own content also takes `from`, `reply_to` and `capture_engagement: false`. Set `migrated_from` in your config to tell PostHog which email provider you moved from. A failed call returns `{:error, %PostHog.Emails.Error{}}` with PostHog's `code` and the HTTP `status_code`. + +Docs: [Send transactional emails with Elixir](https://posthog.com/docs/workflows/transactional-emails/elixir) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).