Sending email as part of an action

There are four primary ways you can go about it, and they all depend on your requirements.

After action hooks - (at least once)

These are transactional with the create. What this means is that you could send an email, and then the comment could fail to create.

i.e

change after_action(fn changeset, result, context -> 
  send_email("...")
  boom!() # now you've sent an email for a comment that was never created
end)

After transaction hooks - (at most once)

These happen after the transaction. That means that the transaction can be committed, and then an error can occur, and so no email goes out.

change after_action(fn 
  changeset, {:ok, result}, context -> 
    boom!() # now you've created a comment where no email goes out
    send_email("...")
   changeset, {:error, error}, context ->
    {:error, error}
end)

Notifiers - (at most once)

This has the same properties as after transaction hooks, but comes in a different form factor.

defmodule SendNewCommentEmail do
  def notify(notification) do
    boom!() # now you've created a comment where no email goes out
    send_email(...)
  end
end

create :create do
  notifiers [SendNewCommentEmail]
end

Oban

This can technically apply to any durable & transactionally schedulable background queue, but Oban is far and above the obvious choice here in the Elixir ecosystem. This is how I would do it. @sezaru is absolutely correct.

create :create do
  change after_action(fn changeset, result -> 
    insert oban job
  end)
end

AshOban

For emails, I also prefer to create a resource (and in this case then a database table) for each email I wish to send. Then, I’ll use AshOban to send the emails using a trigger. It looks like this:

defmodule MyEmail do
  use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [AshOban]

  attributes do
    uuid_primary_key :id
    attribute :state, :atom, constraints: [one_of: [:pending, :sent]]
  end

  actions do
     defaults [:create]

     update :send do
        change set_attribute(:state, :sent)
        change after_transaction(fn changeset, {:ok, result}, context -> 
           send_email(...)
        end)
    end
  end

  oban do
    triggers do
      trigger :send do
        where expr(state == :pending)
      end
    end
  end
end

Then, instead of inserting an Oban job in an after action hook, you can insert the email resource. THen, you can do things like show pending and/or sent emails in your application state, etc. You can also cancel the sending of any given email by deleting the email row, or setting its state to :sent, etc. Lots of small benefits from this strategy that stack up.

2 Likes