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).
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.
I’ve expanded on this in a substack post
As always @zachdaniel you go above and beyond.