Give email delivery an outbox before it has anywhere to send

66556a38d716 · AtlantisPleb · · parent c96a1ba2c34f

Give email delivery an outbox before it has anywhere to send

The issue names the hard part plainly: email is where "delivery
retries" stops being a phrase and starts being a schedule. An
in-product notification is durable by construction — the record is
written in the same transaction as the event — but an outbound send
can fail after that transaction commits.

This is that schedule, and nothing else. It extends the durable effect
outbox this repository already has (EFFECT-001, EFFECT-002) rather
than standing up a parallel one: an `email.delivery` effect keyed to
the notification's dedupe_key, so the same notification cannot be
delivered twice however many times it is enqueued, with the attempt
counts, backoff, leases, and terminal state the effects plane already
enforces.

There is nowhere to send yet, and the code says so rather than
pretending: a delivery with no recipient reaches a terminal
`nothing_to_send_to` outcome, which is a truthful end state and not a
failure to retry.

Deliberately out of scope, because both are decisions rather than
code: whether the address comes from a `user:email` OAuth scope or
from settings, and which mail provider to use. No email column, no
OAuth scope change, no provider dependency, no send. The adapter is a
seam a future decision fills.

Built by a Devin child through the openagents coder's delegate tool;
1,712 notification, effect, and web tests re-run before landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 360 · 2026-08-25T10:40:10.702158Z

Changed files

  • modified lib/openagents/effects.ex
  • modified lib/openagents/effects/effect.ex
  • added lib/openagents/effects/handlers/email_delivery.ex
  • modified lib/openagents/effects/registry.ex
  • modified lib/openagents/effects/worker.ex
  • added lib/openagents/notifications/delivery.ex
  • added lib/openagents/notifications/delivery/adapter.ex
  • added lib/openagents/notifications/delivery/null_adapter.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825102408_add_email_delivery_result_to_effects.exs
  • added test/openagents/notifications/delivery_test.exs

Diff

11 files changed, +302 -5

lib/openagents/effects.ex modified +15 -2

@@ -206,9 +206,21 @@ defmodule OpenAgents.Effects do

206 206
  therefore reports success without contradicting the record.
207 207
  """
208 208
  @spec complete(Effect.t() | String.t()) :: {:ok, Effect.t()} | {:error, :not_found}
209
  def complete(%Effect{id: id}), do: complete(id)
209
  def complete(%Effect{id: id}), do: complete(id, nil)
210 210
211
  def complete(id) when is_binary(id) do
211
  def complete(id) when is_binary(id), do: complete(id, nil)
212
213
  @doc """
214
  Record that an effect's handler succeeded, with an optional result payload.
215
216
  A handler that reached a terminal outcome without an external result can pass
217
  `nil`; a handler that produced an explicit result map can record it. The same
218
  idempotency rule applies: a completed effect returns its existing row.
219
  """
220
  @spec complete(Effect.t() | String.t(), map() | nil) :: {:ok, Effect.t()} | {:error, :not_found}
221
  def complete(%Effect{id: id}, result), do: complete(id, result)
222
223
  def complete(id, result) when is_binary(id) do
212 224
    now = DateTime.utc_now()
213 225
214 226
    {_count, updated} =

@@ -219,6 +231,7 @@ defmodule OpenAgents.Effects do

219 231
          lease_owner: nil,
220 232
          lease_expires_at: nil,
221 233
          last_error: nil,
234
          result: result,
222 235
          completed_at: now,
223 236
          updated_at: now
224 237
        ]
lib/openagents/effects/effect.ex modified +3 -1

@@ -41,6 +41,7 @@ defmodule OpenAgents.Effects.Effect do

41 41
    field :lease_owner, :string
42 42
    field :lease_expires_at, :utc_datetime_usec
43 43
    field :last_error, :string
44
    field :result, :map
44 45
45 46
    field :claimed_at, :utc_datetime_usec
46 47
    field :completed_at, :utc_datetime_usec

@@ -66,7 +67,8 @@ defmodule OpenAgents.Effects.Effect do

66 67
      :source_sequence,
67 68
      :idempotency_key,
68 69
      :maximum_attempts,
69
      :available_at
70
      :available_at,
71
      :result
70 72
    ])
71 73
    |> validate_required([
72 74
      :kind,
lib/openagents/effects/handlers/email_delivery.ex added +36

@@ -0,0 +1,36 @@

1
defmodule OpenAgents.Effects.Handlers.EmailDelivery do
2
  @moduledoc """
3
  Drives an `email.delivery` effect to a terminal state.
4
5
  The payload carries the notification `dedupe_key` and optional `data`. When
6
  `data` does not contain a `to` recipient, the handler records a successful
7
  `nothing_to_send_to` terminal outcome. Otherwise it calls the configured
8
  `OpenAgents.Notifications.Delivery` adapter, which is a future seam: no real
9
  send happens unless a provider is configured.
10
  """
11
12
  @behaviour OpenAgents.Effects.Handler
13
14
  alias OpenAgents.Effects.Effect
15
  alias OpenAgents.Notifications.Delivery
16
17
  @impl OpenAgents.Effects.Handler
18
  def run(%Effect{payload: payload}, _idempotency_key) do
19
    data = Map.get(payload, "data", %{})
20
21
    case recipient(data) do
22
      nil ->
23
        {:ok, %{"outcome" => "nothing_to_send_to"}}
24
25
      to ->
26
        Delivery.adapter().deliver(to, data)
27
    end
28
  end
29
30
  defp recipient(data) when is_map(data) do
31
    case Map.get(data, "to") do
32
      value when is_binary(value) and value != "" -> value
33
      _ -> nil
34
    end
35
  end
36
end
lib/openagents/effects/registry.ex modified +2 -1

@@ -12,7 +12,8 @@ defmodule OpenAgents.Effects.Registry do

12 12
  """
13 13
14 14
  @default_handlers %{
15
    "work.launch_worker" => OpenAgents.Effects.Handlers.WorkLaunch
15
    "work.launch_worker" => OpenAgents.Effects.Handlers.WorkLaunch,
16
    "email.delivery" => OpenAgents.Effects.Handlers.EmailDelivery
16 17
  }
17 18
18 19
  @doc "Every admitted effect kind and its handler."
lib/openagents/effects/worker.ex modified +4

@@ -97,6 +97,10 @@ defmodule OpenAgents.Effects.Worker do

97 97
        {:ok, _effect} = Effects.complete(effect)
98 98
        :ok
99 99
100
      {:ok, %{} = result} ->
101
        {:ok, _effect} = Effects.complete(effect, result)
102
        :ok
103
100 104
      {:ok, _result} ->
101 105
        {:ok, _effect} = Effects.complete(effect)
102 106
        :ok
lib/openagents/notifications/delivery.ex added +80

@@ -0,0 +1,80 @@

1
defmodule OpenAgents.Notifications.Delivery do
2
  @moduledoc """
3
  Durable outbound email delivery seam for notifications.
4
5
  Enqueues one `email.delivery` effect keyed to a notification `dedupe_key`.
6
  A recipient is optional in the caller's data; if none is provided the handler
7
  records a terminal `nothing_to_send_to` outcome instead of a failure.
8
9
  No real send happens here. The adapter is a future seam: the default
10
  `OpenAgents.Notifications.Delivery.NullAdapter` refuses unless a real
11
  provider is configured, and the caller supplies a `to` address.
12
  """
13
14
  alias OpenAgents.Effects
15
  alias OpenAgents.Notifications.Notification
16
17
  @kind "email.delivery"
18
  @source_kind "notification"
19
20
  @doc """
21
  Enqueue an email delivery for a `Notification` or a bare `dedupe_key`.
22
23
  Accepted shapes:
24
    * `%Notification{}`
25
    * a map or keyword with `:dedupe_key` and optional `:data`, `:user_id`,
26
      `:notification_id`, and `:maximum_attempts`
27
28
  When a `user_id` is known, the idempotency key is scoped to that user so the
29
  same `dedupe_key` for two different accounts stays two distinct deliveries.
30
  """
31
  @spec enqueue(Notification.t() | map() | keyword()) ::
32
          {:ok, Effects.Effect.t()} | {:error, term()}
33
  def enqueue(%Notification{} = notification) do
34
    enqueue(%{
35
      dedupe_key: notification.dedupe_key,
36
      user_id: notification.user_id,
37
      notification_id: notification.id,
38
      data: %{}
39
    })
40
  end
41
42
  def enqueue(attrs) when is_list(attrs), do: enqueue(Map.new(attrs))
43
44
  def enqueue(attrs) when is_map(attrs) do
45
    dedupe_key = fetch!(attrs, :dedupe_key)
46
    user_id = Map.get(attrs, :user_id)
47
    notification_id = Map.get(attrs, :notification_id)
48
    data = Map.get(attrs, :data) || %{}
49
50
    idempotency_source = if user_id, do: "#{user_id}/#{dedupe_key}", else: dedupe_key
51
52
    Effects.enqueue(@kind, %{
53
      payload: %{
54
        "dedupe_key" => dedupe_key,
55
        "user_id" => user_id,
56
        "notification_id" => notification_id,
57
        "data" => data
58
      },
59
      source_kind: @source_kind,
60
      source_id: dedupe_key,
61
      idempotency_key: Effects.idempotency_key(@kind, @source_kind, idempotency_source),
62
      maximum_attempts: Map.get(attrs, :maximum_attempts, 5)
63
    })
64
  end
65
66
  @doc "The configured delivery adapter. Defaults to the no-op NullAdapter."
67
  @spec adapter() :: module()
68
  def adapter do
69
    :openagents
70
    |> Application.get_env(__MODULE__, [])
71
    |> Keyword.get(:adapter, OpenAgents.Notifications.Delivery.NullAdapter)
72
  end
73
74
  defp fetch!(attrs, key) do
75
    case Map.fetch(attrs, key) do
76
      {:ok, value} when value != nil -> value
77
      _ -> raise ArgumentError, "delivery enqueue requires #{inspect(key)}"
78
    end
79
  end
80
end
lib/openagents/notifications/delivery/adapter.ex added +14

@@ -0,0 +1,14 @@

1
defmodule OpenAgents.Notifications.Delivery.Adapter do
2
  @moduledoc """
3
  Behaviour for a future email delivery adapter.
4
5
  The adapter is a seam: nothing here performs a real send. A concrete
6
  implementation receives the recipient address and the caller's data map and
7
  returns either an ok result map or an error reason that the durable outbox
8
  will retry.
9
  """
10
11
  @doc "Deliver `data` to `recipient`. Returns `:ok`, `{:ok, result}`, or `{:error, reason}`."
12
  @callback deliver(recipient :: String.t() | nil, data :: map()) ::
13
              :ok | {:ok, map()} | {:error, term()}
14
end
lib/openagents/notifications/delivery/null_adapter.ex added +15

@@ -0,0 +1,15 @@

1
defmodule OpenAgents.Notifications.Delivery.NullAdapter do
2
  @moduledoc """
3
  Default no-op email delivery adapter.
4
5
  No provider is configured, so a delivery that has a recipient cannot be sent.
6
  The handler returns an error and the durable outbox retries up to its
7
  `maximum_attempts`. A delivery without a recipient is handled before the
8
  adapter is ever called.
9
  """
10
11
  @behaviour OpenAgents.Notifications.Delivery.Adapter
12
13
  @impl true
14
  def deliver(_recipient, _data), do: {:error, :no_provider_configured}
15
end
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -298,7 +298,8 @@

298 298
    20260824230730,
299 299
    20260824231951,
300 300
    20260825024500,
301
    20260825054906
301
    20260825054906,
302
    20260825102408
302 303
  ],
303 304
  "required_tables": [
304 305
    "users",
priv/repo/migrations/20260825102408_add_email_delivery_result_to_effects.exs added +9

@@ -0,0 +1,9 @@

1
defmodule OpenAgents.Repo.Migrations.AddEmailDeliveryResultToEffects do
2
  use Ecto.Migration
3
4
  def change do
5
    alter table(:effects) do
6
      add :result, :map
7
    end
8
  end
9
end
test/openagents/notifications/delivery_test.exs added +122

@@ -0,0 +1,122 @@

1
defmodule OpenAgents.Notifications.DeliveryTest do
2
  @moduledoc """
3
  Proofs for the durable, decision-independent outbound email seam.
4
5
  These tests intentionally do not configure a real provider. They exercise the
6
  outbox invariants (idempotency, retry, terminal failure, and no-recipient
7
  handling) through the `email.delivery` effect.
8
  """
9
10
  use OpenAgents.DataCase, async: false
11
12
  alias OpenAgents.Effects
13
  alias OpenAgents.Effects.Effect
14
  alias OpenAgents.Effects.Worker
15
  alias OpenAgents.Notifications.Delivery
16
17
  defmodule FailingAdapter do
18
    @behaviour OpenAgents.Notifications.Delivery.Adapter
19
20
    @impl true
21
    def deliver(_recipient, _data), do: {:error, :test_failure}
22
  end
23
24
  setup do
25
    Application.put_env(:openagents, OpenAgents.Notifications.Delivery, adapter: FailingAdapter)
26
27
    on_exit(fn ->
28
      Application.delete_env(:openagents, OpenAgents.Notifications.Delivery)
29
    end)
30
31
    :ok
32
  end
33
34
  describe "enqueue/1" do
35
    test "records one delivery per dedupe key, no matter how many times it is enqueued" do
36
      {:ok, first} = Delivery.enqueue(dedupe_key: "issue:1:opened", user_id: "user-a")
37
      {:ok, second} = Delivery.enqueue(dedupe_key: "issue:1:opened", user_id: "user-a")
38
39
      assert first.id == second.id
40
      assert Repo.aggregate(Effect, :count) == 1
41
      assert first.payload["dedupe_key"] == "issue:1:opened"
42
    end
43
44
    test "a different user with the same dedupe key gets a distinct delivery" do
45
      {:ok, first} = Delivery.enqueue(dedupe_key: "issue:1:opened", user_id: "user-a")
46
      {:ok, second} = Delivery.enqueue(dedupe_key: "issue:1:opened", user_id: "user-b")
47
48
      refute first.id == second.id
49
    end
50
  end
51
52
  describe "handler dispatch" do
53
    test "a failed attempt increments attempts and reschedules the next try" do
54
      {:ok, effect} =
55
        Delivery.enqueue(
56
          dedupe_key: "fail-once",
57
          user_id: "user-a",
58
          data: %{"to" => "test@example.com"}
59
        )
60
61
      before = DateTime.utc_now()
62
      assert %{completed: 0, failed: 1} = Worker.run_once(identity: "worker-a")
63
64
      failed = Effects.get(effect.id)
65
      assert failed.status == "pending"
66
      assert failed.attempts == 1
67
      assert failed.last_error =~ "test_failure"
68
      assert DateTime.compare(failed.available_at, before) == :gt
69
70
      later = DateTime.add(failed.available_at, 1, :second)
71
      assert [retried] = Effects.claim_batch("worker-b", now: later)
72
      assert retried.attempts == 2
73
    end
74
75
    test "attempts stop at a terminal failed state" do
76
      {:ok, effect} =
77
        Delivery.enqueue(
78
          dedupe_key: "doomed",
79
          user_id: "user-a",
80
          data: %{"to" => "test@example.com"},
81
          maximum_attempts: 2
82
        )
83
84
      assert %{completed: 0, failed: 1} = Worker.run_once(identity: "worker-a")
85
      first = Effects.get(effect.id)
86
      assert first.status == "pending"
87
      assert first.attempts == 1
88
89
      later = DateTime.add(first.available_at, 1, :second)
90
      assert [claimed] = Effects.claim_batch("worker-b", now: later)
91
      assert claimed.attempts == 2
92
      assert :error = Worker.dispatch(claimed)
93
94
      dead = Effects.get(effect.id)
95
      assert dead.status == "failed"
96
      assert dead.attempts == 2
97
      assert dead.completed_at != nil
98
      assert dead.lease_owner == nil
99
100
      far_future = DateTime.add(dead.available_at, 3_600, :second)
101
      assert Effects.claim_batch("worker-c", now: far_future) == []
102
    end
103
104
    test "a delivery with no recipient records nothing to send to, not a failure" do
105
      {:ok, effect} =
106
        Delivery.enqueue(
107
          dedupe_key: "no-recipient",
108
          user_id: "user-a",
109
          data: %{"subject" => "hello"}
110
        )
111
112
      assert %{completed: 1, failed: 0} = Worker.run_once(identity: "worker-a")
113
114
      done = Effects.get(effect.id)
115
      assert done.status == "done"
116
      assert done.attempts == 1
117
      assert done.result == %{"outcome" => "nothing_to_send_to"}
118
      assert done.last_error == nil
119
      assert done.completed_at != nil
120
    end
121
  end
122
end

This page updates live while a promote is in flight · changelog