diff --git a/.sampo/changesets/emails-send.md b/.sampo/changesets/emails-send.md new file mode 100644 index 0000000..cb0354f --- /dev/null +++ b/.sampo/changesets/emails-send.md @@ -0,0 +1,32 @@ +--- +hex/posthog: minor +--- + +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). 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..bd651e8 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 - 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_secret_key(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/config_schema.ex b/lib/posthog/config_schema.ex index a1c742b..6db4b89 100644 --- a/lib/posthog/config_schema.ex +++ b/lib/posthog/config_schema.ex @@ -43,9 +43,45 @@ 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 `transactional_email:write` + scope to send email, or `transactional_email:read` to read an email's status. + """ + ], + app_host: [ + 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` + 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`, 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: [ + 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 +187,31 @@ 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 + {: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..86d862f --- /dev/null +++ b/lib/posthog/emails.ex @@ -0,0 +1,925 @@ +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 the "Send transactional email" preset + (`transactional_email:write`) in your project settings. Add `transactional_email:read` too + if you read email statuses with a key that can't send. Keys made with `hog_flow:write` + still work. The project API key (`phc_...`) captures events but cannot send email. + 3. Set it as `:secret_key` in your PostHog config: + + config :posthog, + api_host: "https://eu.i.posthog.com", + api_key: "phc_...", + secret_key: "phs_..." + + ## Examples + + Send a template made in PostHog: + + {:ok, %PostHog.Emails.SendResult{id: message_id}} = + PostHog.Emails.send( + to: "user@example.com", + template_id: "0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b", + template_data: %{name: "Ada"}, + distinct_id: "user_123", + idempotency_key: "welcome:user_123" + ) + + Send content your code rendered: + + PostHog.Emails.send( + to: "user@example.com", + template_key: "password-reset", + subject: "Reset your password", + html: rendered_html, + distinct_id: "user_123", + idempotency_key: "password-reset:\#{reset.id}" + ) + + Read what happened to it: + + {:ok, %PostHog.Emails.Status{status: status}} = PostHog.Emails.get(message_id) + + Always pass `:distinct_id`. Without it, the email's events go to a person whose distinct ID + is the email address, not to your user. The SDK never takes it from `PostHog.set_context/2`. + + ## What a result means + + `{:ok, result}` means PostHog queued the email. Delivery happens later: read it with `get/2`, + or in the template's workflow in PostHog. + + ## Where requests go + + A client on PostHog Cloud sends to its region's app host, `https://us.posthog.com` or + `https://eu.posthog.com`. For any other `:api_host`, such as a reverse proxy, set `:app_host` + in the config, because email requests must not send the secret key to the proxy. Without it, + a send returns the error `missing_app_host`. + + `:secret_key` also turns on local feature flag evaluation, which fetches flag definitions + from `:api_host` with that key. When `:api_host` is a proxy that must not see the key, set + `enable_local_evaluation: false` as well. + + ## Timeouts, retries and idempotency + + Each request times out after `:email_request_timeout_ms` (15 seconds by default). A send + retries network errors, rate limits, an idempotency key still in flight and an unavailable + service `:email_request_max_retries` times (2 by default), waiting about 1 and then 2 seconds, + or the server's `Retry-After` when it is longer. A `Retry-After` over 30 seconds fails at once + with `:retry_after_ms` set. A monthly quota is never retried. + + Every attempt sends the same idempotency key, so a retry never sends twice. Without + `:idempotency_key`, the SDK makes a UUID for the call. When a job runner such as Oban + already retries the send, pass `max_retries: 0` and an `:idempotency_key` that names the + email, such as `"welcome:user_123"`. A generated key doesn't survive a job retry. + + Sends and status reads block the calling process until they finish. The SDK keeps no email + queue. + + ## Errors + + Every failure returns `{:error, %PostHog.Emails.Error{}}`. Branch on `:code`. The ones a + setup usually hits: + + - `missing_secret_key`: the config has no `:secret_key`. + - `missing_app_host`: the `:api_host` is not PostHog Cloud. Set `:app_host`. + - `authentication_failed` (401) or `permission_denied` (403): the key is wrong, or it lacks + `transactional_email:write`. + - `not_found` (404): the template ID is wrong, or transactional email is not enabled for the + project. + - `client_disabled`: the PostHog instance is not running or has no `:api_key`, so it sends + nothing. + - `client_closed`: the PostHog instance stopped while a call waited to retry, so the retry + was not sent. + + An option the SDK refuses returns an error with an `invalid_*` code, such as + `invalid_template_id`, and the name `:validation_error`, before any request. + + ## Testing + + Requests go through the configured `:api_client_module`, so a `Mox` mock of + `PostHog.API.Client` sees every email request without network calls. + """ + + 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 + @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 `transactional_email:read` can read + statuses but not send. Network errors, rate limits and an unavailable service are retried. + + ## Examples + + {:ok, %PostHog.Emails.Status{status: "bounced", reason: reason}} = + PostHog.Emails.get(message_id) + """ + @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: UUIDv7.decode(value) != :error + + 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) + + UUIDv7.encode(<>) + 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, + %{ + name: name, + nothing_done: nothing_done, + api_client: config.api_client, + app_host: app_host, + secret_key: secret_key, + migrated_from: Map.get(config, :migrated_from), + timeout_ms: config.email_request_timeout_ms, + max_retries: config.email_request_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 + + 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 + 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 transactional_email:write scope.", + else: + "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 + 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 + # 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 within_deadline(connection.timeout_ms, fn -> + 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)), + retry_after_ms(Map.get(response, :headers))} + + {:error, _reason} -> + {:error, Error.new("network_error", "The request to PostHog failed.")} + 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 + # 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() + # Keeps test allowances, such as Mox's, for the worker. + callers = [caller | Process.get(:"$callers", [])] + + {worker, 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} -> + 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 + + # 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 + 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, :exit, reason, _stacktrace}), do: {:error, {:client_exit, reason}} + + defp unwrap({:caught, :throw, value, stacktrace}), + do: :erlang.raise(:error, {:nocatch, value}, stacktrace) + + 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) + 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" + # 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..2aaed8d --- /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"`, + `"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. + - `: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_default_instance_test.exs b/test/posthog/emails_default_instance_test.exs new file mode 100644 index 0000000..8678a61 --- /dev/null +++ b/test/posthog/emails_default_instance_test.exs @@ -0,0 +1,59 @@ +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 + + # 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 + + 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 new file mode 100644 index 0000000..b249e9c --- /dev/null +++ b/test/posthog/emails_test.exs @@ -0,0 +1,955 @@ +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 + + 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}) + if context[:stop_instance_on_sleep], do: stop_supervised!(context.test) + 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") == 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 =~ "transactional_email:write scope" + 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 + + 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) + + 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 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 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 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 + + 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 + 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 "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 + + 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