lib/openagents/notifications/delivery.ex

main at 58e6347eeb72 · 2 KB

defmodule OpenAgents.Notifications.Delivery do
  @moduledoc """
  The durable outbound half of a notification.

  In-product delivery needs no queue: the record is written in the transaction
  that writes the comment it announces, so it exists exactly when the event
  does. Email cannot work that way. A send happens after that transaction
  commits and can fail on somebody else's server, so the asking has to be
  durable even though the doing is not.

  `enqueue/1` is called inside the same transaction as the notification row and
  writes one `email.delivery` effect. From there `OpenAgents.Effects` owns the
  schedule: a lease per attempt, exponential backoff between them, and a
  terminal `failed` at `maximum_attempts`. The effect's idempotency key is the
  recipient and the notification's `dedupe_key`, which is the same key the
  unique index on `notifications` uses, so a replayed fan-out produces one
  record and one send rather than two of each.

  ## What the payload may carry

  Identifiers, and no address. The recipient is resolved at send time by
  `OpenAgents.Notifications.email_dispatch/1` from the account's confirmed
  address, so a queued delivery cannot outlive the consent it was queued under:
  an account that removes its address, switches the channel off, or loses
  access to the repository stops being mailed, including for effects already
  sitting in the queue.
  """

  alias OpenAgents.Effects

  @kind "email.delivery"
  @source_kind "notification"

  @doc """
  Enqueue the email half of one notification.

  Requires `:dedupe_key`, `:user_id`, `:issue_id`, and `:kind`, and accepts
  `:actor_login` and `:maximum_attempts`. The idempotency key is scoped to the
  account, so the same event for two recipients is two deliveries.
  """
  @spec enqueue(map() | keyword()) :: {:ok, Effects.Effect.t()} | {:error, term()}
  def enqueue(attrs) when is_list(attrs), do: enqueue(Map.new(attrs))

  def enqueue(attrs) when is_map(attrs) do
    dedupe_key = fetch!(attrs, :dedupe_key)
    user_id = fetch!(attrs, :user_id)
    issue_id = fetch!(attrs, :issue_id)
    kind = fetch!(attrs, :kind)

    Effects.enqueue(@kind, %{
      payload: %{
        "dedupe_key" => dedupe_key,
        "user_id" => user_id,
        "issue_id" => issue_id,
        "kind" => kind,
        "actor_login" => Map.get(attrs, :actor_login)
      },
      source_kind: @source_kind,
      source_id: dedupe_key,
      idempotency_key: Effects.idempotency_key(@kind, @source_kind, "#{user_id}/#{dedupe_key}"),
      maximum_attempts: Map.get(attrs, :maximum_attempts, 5)
    })
  end

  @doc "The configured delivery adapter. Defaults to the no-op NullAdapter."
  @spec adapter() :: module()
  def adapter do
    :openagents
    |> Application.get_env(__MODULE__, [])
    |> Keyword.get(:adapter, OpenAgents.Notifications.Delivery.NullAdapter)
  end

  defp fetch!(attrs, key) do
    case Map.fetch(attrs, key) do
      {:ok, value} when value != nil -> value
      _ -> raise ArgumentError, "delivery enqueue requires #{inspect(key)}"
    end
  end
end