Sending email as part of an action

I am currently implementing sending an email after e.g. a Comment resource is created.
I am doing it as a Changeset.after_action is this the right place to do it or should I be looking elsewhere?

Here is how I do it:

First, I create an after_action that inserts an Oban job to send the e-mail. And and then I let Oban handle the e-mail sending part in a worker.

The advantage here is that even if the e-mail service is down, the action will still work and then you can handle the oban job as you wish (by retrying, etc).

This also means that the action will not be bottleneck by an slow e-mail service just because they are blocking the action to finish in the after_action call (because it is inside a DB transaction).

1 Like

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

I’ve expanded on this in a substack post :slight_smile:

4 Likes

As always @zachdaniel you go above and beyond.