Delivery Receipts

View Source

Delivery receipts track the lifecycle of every notification sent through AshDispatch. This guide covers the receipt model, status flow, and available actions for managing deliveries.

What Delivery Receipts Track

Every time a notification is dispatched, a DeliveryReceipt is created to track:

  • Recipient information - Who the notification is for (email, user_id, etc.)
  • Content - Full email subject, body, and any additional content
  • Status - Current delivery state (pending, sent, failed, etc.)
  • Provider data - Response from email provider, message IDs
  • Timing - When created, sent, delivered, opened, clicked
  • Source - What triggered the notification (order, ticket, etc.)

This provides a complete audit trail and enables:

  • Retry failed deliveries
  • Debug delivery issues
  • Track engagement (opens, clicks)
  • User notification history

Status Flow

Delivery receipts follow a state machine pattern:

                    ┌─────────────┐
                    │   pending   │
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │scheduled │ │ skipped  │ │  (sync)  │
        └────┬─────┘ └──────────┘ └────┬─────┘
             │                         │
             ▼                         │
        ┌──────────┐                   │
        │ sending  │◄──────────────────┘
        └────┬─────┘
             │
       ┌─────┴─────┐
       ▼           ▼
  ┌────────┐  ┌────────┐
  │  sent  │  │ failed │──────┐
  └────────┘  └────┬───┘      │
                   │          │ (max retries)
                   │ (retry)  ▼
                   │    ┌─────────────────┐
                   └───►│ failed_permanent │
                        └─────────────────┘

Status Descriptions

StatusDescription
pendingJust created, not yet processed
scheduledOban job enqueued for async delivery
sendingCurrently being sent
sentSuccessfully delivered to provider
failedDelivery failed, may be retried
failed_permanentWon't retry (invalid email, unsubscribed)
skippedIntentionally not sent (user opted out)

Available Actions

Core Status Transitions

These actions are typically called by workers and internal processes:

# Mark as sending (worker starting delivery)
Ash.update(receipt, :mark_sending)

# Mark as sent (delivery successful)
Ash.update(receipt, :mark_sent, %{
  provider_id: "msg_abc123",
  provider_response: %{"status" => "sent"}
})

# Mark as failed (delivery failed, will retry)
Ash.update(receipt, :mark_failed, %{
  error_message: "Connection timeout"
})

# Mark as permanently failed (won't retry)
Ash.update(receipt, :mark_failed_permanent, %{
  error_message: "Invalid email address"
})

# Skip delivery (user opted out)
Ash.update(receipt, :skip, %{
  error_message: "User disabled notifications"
})

Manual Actions (Admin UI)

send_now

The send_now action allows administrators to manually trigger delivery for a scheduled or pending receipt. This is useful for:

  • Retrying stuck deliveries - When a job is stuck in scheduled state
  • Testing in production - Trigger a specific email immediately
  • Support requests - Resend a notification to a user

Usage:

# From admin UI or IEx
receipt
|> Ash.Changeset.for_update(:send_now, %{}, actor: current_admin)
|> Ash.update(authorize?: true)

Behavior:

  • Creates a new Oban job to process the delivery immediately
  • Only works from scheduled or pending status
  • Respects configured authorization (see below)

Authorization:

By default, any authenticated actor can use send_now. To restrict it (e.g., super admins only), configure an authorizer:

# config/config.exs
config :ash_dispatch,
  send_now_authorizer: MyApp.Deliveries.SendNowAuthorizer
# lib/my_app/deliveries/send_now_authorizer.ex
defmodule MyApp.Deliveries.SendNowAuthorizer do
  def authorize(%{super_admin: true}), do: :ok
  def authorize(_actor), do: {:error, "Only super admins can manually trigger email sending"}
end

See Configuration for full documentation.

retry

The retry action re-queues a failed delivery for another attempt:

receipt
|> Ash.Changeset.for_update(:retry, %{}, actor: current_admin)
|> Ash.update(authorize?: true)

Behavior:

  • Only works from failed status
  • Increments retry_count
  • Creates new Oban job
  • Validates against max retry limit

Recording Webhook Events

When email providers send webhooks (delivery confirmations, opens, clicks), use record_webhook_event:

Ash.update(receipt, :record_webhook_event, %{
  delivered_at: DateTime.utc_now(),
  provider_response: webhook_payload
})

Supported event timestamps:

  • sent_at - Email accepted by provider
  • delivered_at - Email delivered to inbox
  • delivery_delayed_at - Delivery delayed
  • failed_at - Delivery failed
  • opened_at - Email opened
  • clicked_at - Link clicked
  • bounced_at - Email bounced
  • complained_at - Spam complaint received

Querying Receipts

List all receipts (admin)

DeliveryReceipt
|> Ash.Query.for_read(:list_all, %{
  status: :failed,
  transport: :email
})
|> Ash.read(actor: admin, authorize?: true)

List for specific user

DeliveryReceipt
|> Ash.Query.for_read(:list_for_user, %{user_id: user_id})
|> Ash.read(actor: admin, authorize?: true)

Find by provider ID (webhooks)

DeliveryReceipt
|> Ash.Query.for_read(:get_by_provider_id, %{provider_id: "msg_abc123"})
|> Ash.read_one(authorize?: false)

Calculated Fields

Delivery receipts include useful calculated fields:

FieldDescription
oban_jobThe associated Oban job (if any)
source_urlURL path to the source resource
source_labelHuman-readable label for source type
admin_urlAdmin-specific URL for the source
from_emailSender email address extracted from content
from_nameSender name extracted from content

These are useful for building admin UIs that link back to the originating record.

Sender Information

The from_email and from_name calculations extract sender information from the stored content field:

# Load sender info with the receipt
receipt = Ash.get!(DeliveryReceipt, id, load: [:from_email, :from_name])

# Display sender
"#{receipt.from_name} <#{receipt.from_email}>"
# => "Acme <noreply@acme.test>"

This is useful for:

  • Displaying the sender in admin UIs
  • Debugging which domain emails were sent from (staging vs production)
  • Filtering receipts by sender domain

Building an Admin UI

A typical delivery receipt admin interface might include:

List View

  • Filter by status, transport, event_id, audience
  • Show recipient, status, sent_at
  • Actions: View, Retry, Send Now

Detail View

  • Full receipt information
  • Email content preview (subject, body)
  • Provider response
  • Timeline of status changes
  • Link to source resource
  • Oban job details

Example LiveView

def handle_event("send_now", %{"id" => id}, socket) do
  receipt = Deliveries.get_receipt!(id)

  case receipt
       |> Ash.Changeset.for_update(:send_now, %{}, actor: socket.assigns.current_user)
       |> Ash.update(authorize?: true) do
    {:ok, _} ->
      {:noreply, put_flash(socket, :info, "Email queued for immediate delivery")}

    {:error, error} ->
      {:noreply, put_flash(socket, :error, Exception.message(error))}
  end
end

Automatic Retry

AshDispatch includes automatic retry for failed deliveries via Oban cron:

# In your Oban config
config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       # Retry failed emails every 15 minutes
       {"*/15 * * * *", AshDispatch.Workers.RetryFailedEmails, max_attempts: 1}
     ]}
  ]

The retry worker:

  • Finds receipts in failed status with retry_count < max_retries
  • Re-queues them for delivery
  • Marks permanently failed after max retries exceeded

Best Practices

1. Use ReceiptStatus helper

For status updates in custom workers, use the centralized helper:

alias AshDispatch.ReceiptStatus

# Instead of manual changeset creation
{:ok, receipt} = ReceiptStatus.mark_sending(receipt)
ReceiptStatus.mark_sent(receipt, provider_response)

2. Store provider IDs

Always store provider message IDs for webhook correlation:

ReceiptStatus.mark_sent(receipt, %{
  "id" => provider_message_id,
  "status" => "queued"
})

3. Include source information

When creating receipts, include source type and ID for traceability:

%{
  event_id: "orders.shipped",
  source_type: "Elixir.MyApp.Orders.Order",
  source_id: order.id,
  # ...
}

4. Respect user preferences

Preferences are checked before delivery, but note WHICH key does it — the two are not the same (see Configuration):

config :ash_dispatch,
  # Consulted by every transport before delivery:
  user_preference: MyApp.NotificationPreferences,
  # Consulted by the email worker and manual triggers only:
  preference_provider: MyApp.LegacyPreferences

A receipt skipped by the transport gate carries error_message: "user_opted_out"; one skipped by the email worker's provider path carries "User opted out of this email category". A count of opt-outs has to know about both.

Webhook Signature Verification

Resend webhooks mutate receipt state (opened/bounced/delivered), so verify the Svix signature before handing the payload to process_webhook/1:

case AshDispatch.WebhookHandlers.Resend.verify(raw_body, headers, secret) do
  :ok -> AshDispatch.WebhookHandlers.Resend.process_webhook(params)
  {:error, _} -> # respond 400
end

raw_body must be the request body exactly as received — cache it before your JSON parser runs. See AshDispatch.WebhookHandlers.Resend.verify/4.

Sensitive Content Retention

Receipts store full rendered bodies, which for OTP codes and password-reset links means live secrets in the database. Declare such events with metadata: [sensitive_content: true] and schedule AshDispatch.Workers.ScrubSensitiveContent (cron): bodies older than config :ash_dispatch, :scrub_after_hours (default 24) are blanked while recipient, subject, status and delivery timestamps stay intact. :failed receipts are left for the retry path first.

Next Steps